From ea9481ed47e7b7f1c2dedcf27a8149234af3879d Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 18 Feb 2021 19:29:35 -0800 Subject: [PATCH] rework code (#3) * rework code * Added unit handling Co-authored-by: Eric Charles --- python/cfgmdl/__init__.py | 5 +- python/cfgmdl/array.py | 23 +++ python/cfgmdl/choice.py | 9 +- python/cfgmdl/configurable.py | 189 +++++++++++++++++++++ python/cfgmdl/derived.py | 5 +- python/cfgmdl/function.py | 4 +- python/cfgmdl/model.py | 307 ++-------------------------------- python/cfgmdl/param_holder.py | 67 ++++++++ python/cfgmdl/parameter.py | 133 +++++---------- python/cfgmdl/property.py | 38 +++-- python/cfgmdl/unit.py | 44 +++++ python/cfgmdl/utils.py | 29 +++- tests/test_choice.py | 2 +- tests/test_function.py | 2 + tests/test_model.py | 96 ++++------- tests/test_parameter.py | 31 +++- tests/test_property.py | 27 ++- tests/test_units.py | 34 ++++ 18 files changed, 570 insertions(+), 475 deletions(-) create mode 100644 python/cfgmdl/array.py create mode 100644 python/cfgmdl/configurable.py create mode 100644 python/cfgmdl/param_holder.py create mode 100644 python/cfgmdl/unit.py create mode 100644 tests/test_units.py diff --git a/python/cfgmdl/__init__.py b/python/cfgmdl/__init__.py index 9733b97..a564768 100644 --- a/python/cfgmdl/__init__.py +++ b/python/cfgmdl/__init__.py @@ -4,9 +4,12 @@ __version__ = get_git_version() del get_git_version +from .unit import Unit +from .array import Array from .property import Property +from .derived import Derived +from .configurable import Configurable from .choice import Choice from .parameter import Parameter -from .derived import Derived from .model import Model from .function import Function diff --git a/python/cfgmdl/array.py b/python/cfgmdl/array.py new file mode 100644 index 0000000..8b55d68 --- /dev/null +++ b/python/cfgmdl/array.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +""" Tools to manage array propery objects. +""" +from copy import deepcopy + +import numpy as np + +from .property import Property, defaults_decorator + +class Array(Property): + """Property sub-class numpy arrays + """ + defaults = deepcopy(Property.defaults) + defaults['dtype'] = (float, 'Data type of array element') + defaults['default'] = (np.nan, 'Default value') + + @defaults_decorator(defaults) + def __init__(self, **kwargs): + super(Array, self).__init__(**kwargs) + + def _cast_type(self, value): + """Hook took override type casting""" + return np.array(value).astype(self.dtype) diff --git a/python/cfgmdl/choice.py b/python/cfgmdl/choice.py index 69e972f..c882aca 100644 --- a/python/cfgmdl/choice.py +++ b/python/cfgmdl/choice.py @@ -16,12 +16,11 @@ class Choice(Property): """ # Better to keep the structure consistent with Property - defaults = deepcopy(Property.defaults) + [ - ('choices', [], 'Allowed values'), - ] + defaults = deepcopy(Property.defaults) + defaults['choices'] = ([], 'Allowed values') + # Overwrite the default dtype - idx = [d[0] for d in defaults].index('dtype') - defaults[idx] = ('dtype', str, 'Data type') + defaults['dtype'] = (str, 'Data type') @defaults_decorator(defaults) def __init__(self, **kwargs): diff --git a/python/cfgmdl/configurable.py b/python/cfgmdl/configurable.py new file mode 100644 index 0000000..75ee2c9 --- /dev/null +++ b/python/cfgmdl/configurable.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +""" +Classes used to describe aspect of Models. + +The base class is `Property` which describes any one property of a model, +such as the name, or some other fixed property. + +The `Parameter` class describes variable model parameters. + +The `Derived` class describes model properies that are derived +from other model properties. + +""" +from collections import OrderedDict as odict +from collections.abc import Mapping + +from .property import Property +from .derived import Derived + +from .utils import Meta, model_docstring + +class Configurable: + """Base class for Configurable objects + + Examples:: + + # A simple class with some managed Properties + class MyClass(Configurable): + + val_float = Property(dtype=float, default=0.0, help="A float value") + val_int = Property(dtype=int, default=1, help="An integer value") + val_required = Property(dtype=float, required=True, help="A float value") + val_str = Property(dtype=str, default="", help="A string value") + val_list = Property(dtype=list, default=[], help="An list value") + val_dict = Property(dtype=list, default={}, help="A dictionary value") + + # A class with nested configuration + class MyPair(Model): + val_first = Property(dtype=MyClass, required=True, help="First MyClass object") + val_second = Property(dtype=MyClass, required=True, help="Second MyClass object") + + + # Default, all Properties take their default values (must provide required Properties) + my_obj = MyClass(val_required=3.) + + # Access Properties + my_obj.val_float + my_obj.val_str + my_obj.val_list + my_obj.val_dict + my_obj.val_required + + # Set Properties + my_obj.val_float = 5.4 + my_obj.val_int = 3 + my_obj.val_dict = dict(a=3, b=4) + + # This will fail with a TypeError + my_obj.val_float = "not a float" + + # Override values in construction + my_obj = MyClass(val_required=3., val_float=4.3, val_int=2, val_str="Hello World") + + # Build nested Configurables + my_pair = MyPair(val_first=dict(val_required=3.), val_second=dict(val_required=4.)) + + my_pair.val_first.val_float + my_pair.val_second.val_int + + """ + + __metaclass__ = Meta + + def __init__(self, **kwargs): + """ C'tor. Build from a set of keyword arguments. + """ + self._properties = self.find_properties() + self._init_properties(**kwargs) + self._cache() + + @classmethod + def find_properties(cls): + """Find properties that belong to this model""" + the_classes = cls.mro() + props = odict() + for the_class in the_classes: + for key, val in the_class.__dict__.items(): + if isinstance(val, Property): + props[key] = val + return props + + def __str__(self, indent=0): + """ Cast model as a formatted string + """ + try: + ret = '{0:>{2}}{1}'.format('', self.name, indent) + except AttributeError: + ret = "%s" % (type(self)) + if not self._properties: #pragma: no cover + return ret + ret += '\n{0:>{2}}{1}'.format('', 'Parameters:', indent + 2) + width = len(max(self._properties.keys(), key=len)) + for name in self._properties.keys(): + value = getattr(self, name) + par = '{0!s:{width}} : {1!r}'.format(name, value, width=width) + ret += '\n{0:>{2}}{1}'.format('', par, indent + 4) + return ret + + @classmethod + def defaults_docstring(cls, header=None, indent=None, footer=None): + """Add the default values to the class docstring""" + return model_docstring(cls, header=header, indent=indent, footer=footer) #pragma: no cover + + def update(self, *args, **kwargs): + """ + Set a group of attributes (parameters and members). Calls + `setp` directly, so kwargs can include more than just the + parameter value (e.g., bounds, free, etc.). + """ + if args: + raise ValueError("Argument passed to Model.upate() %s" % args) + + kwargs = dict(kwargs) + for name, value in kwargs.items(): + # Raise KeyError if Property not found + try: + prop = self._properties[name] + except KeyError as err: + raise KeyError("Warning: %s does not have properties %s" % (type(self), name)) from err + + attr = getattr(self, '_%s' % name) + if isinstance(attr, Configurable): + # Set attributes + if isinstance(value, Mapping): + attr.update(**value) + else: + attr.update(value) + else: + prop.__set__(self, value) + + + def _init_properties(self, **kwargs): + """ Loop through the list of Properties, + extract the derived and required properties and do the + appropriate book-keeping + """ + missing = {} + kwcopy = kwargs.copy() + for k, p in self._properties.items(): + if k not in kwcopy and p.required: + missing[k] = p + + pval = kwcopy.pop(k, p.default) + + p.__set__(self, pval) + if isinstance(p, Derived): + if p.loader is None: + p.loader = self.__getattribute__("_load_%s" % k) + elif isinstance(p.loader, str): + p.loader = self.__getattribute__(p.loader) + if not callable(p.loader): + raise ValueError("Callabe loader not defined for Derived object", type(self), k, p.loader) + if missing: + raise ValueError("One or more required properties are missing ", type(self), missing.keys()) + if kwcopy: + raise KeyError("Warning: %s does not have attributes %s" % (type(self), kwcopy)) + + def todict(self): + """ Return self cast as an '~collections.OrderedDict' object + """ + return odict([(key, val.todict(self)) for key, val in self._properties.items()]) + + + def _cache(self, name=None): + """ + Method called in to cache any computationally + intensive properties after updating the parameters. + + Parameters + ---------- + name : string + The parameter name. + + Returns + ------- + None + """ + #pylint: disable=unused-argument, no-self-use + return diff --git a/python/cfgmdl/derived.py b/python/cfgmdl/derived.py index 6af4b24..2a01ad2 100644 --- a/python/cfgmdl/derived.py +++ b/python/cfgmdl/derived.py @@ -14,9 +14,8 @@ class Derived(Property): This allows specifying the specifying a 'loader' function by name that is used to compute the value of the property. """ - defaults = deepcopy(Property.defaults) + [ - ('loader', None, 'Function to load datum') - ] + defaults = deepcopy(Property.defaults) + defaults['loader'] = (None, 'Function to load datum') @classmethod def dummy(cls): #pragma: no cover diff --git a/python/cfgmdl/function.py b/python/cfgmdl/function.py index 8af09de..9d7f1aa 100644 --- a/python/cfgmdl/function.py +++ b/python/cfgmdl/function.py @@ -12,7 +12,9 @@ def check_inputs(x, inputs): """Convert inputs to arrays""" ret = [] - if not isinstance(x, (Iterable, int, float)): + if x is None: + x = np.nan + elif not isinstance(x, (Iterable, int, float)): raise TypeError("Non-numeric value %s passed in Physics" % (str(x))) x = np.array(x).astype(np.float) diff --git a/python/cfgmdl/model.py b/python/cfgmdl/model.py index 21c6ca2..49dd25b 100644 --- a/python/cfgmdl/model.py +++ b/python/cfgmdl/model.py @@ -15,190 +15,30 @@ import numpy as np -from .property import Property -from .derived import Derived +from .configurable import Configurable from .parameter import Parameter -from .utils import Meta, model_docstring - -class Model: - """Base class for Configurable Models - - Examples:: - - # A simple class with some managed Properties - class MyClass(Model): - - val_float = Property(dtype=float, default=0.0, help="A float value") - val_int = Property(dtype=int, default=1, help="An integer value") - val_required = Property(dtype=float, required=True, help="A float value") - val_str = Property(dtype=str, default="", help="A string value") - val_list = Property(dtype=list, default=[], help="An list value") - val_dict = Property(dtype=list, default={}, help="A dictionary value") - - # A class with nested configuration - class MyPair(Model): - val_first = Property(dtype=MyClass, required=True, help="First MyClass object") - val_second = Property(dtype=MyClass, required=True, help="Second MyClass object") - - - # Default, all Properties take their default values (must provide required Properties) - my_obj = MyClass(val_required=3.) - - # Access Properties - my_obj.val_float - my_obj.val_str - my_obj.val_list - my_obj.val_dict - my_obj.val_required - - # Set Properties - my_obj.val_float = 5.4 - my_obj.val_int = 3 - my_obj.val_dict = dict(a=3, b=4) - - # This will fail with a TypeError - my_obj.val_float = "not a float" - - # Override values in construction - my_obj = MyClass(val_required=3., val_float=4.3, val_int=2, val_str="Hello World") - - # Build nested Configurables - my_pair = MyPair(val_first=dict(val_required=3.), val_second=dict(val_required=4.)) - - my_pair.val_first.val_float - my_pair.val_second.val_int +class Model(Configurable): + """Configurable model with parameter management """ - - __metaclass__ = Meta - def __init__(self, **kwargs): """ C'tor. Build from a set of keyword arguments. """ - self._properties, self._params = self.find_properties() - self._init_properties(**kwargs) - self._cache() + super(Model, self).__init__(**kwargs) + self._params = self.find_params() @classmethod - def find_properties(cls): + def find_params(cls): """Find properties that belong to this model""" the_classes = cls.mro() - props = odict() params = odict() for the_class in the_classes: for key, val in the_class.__dict__.items(): - if isinstance(val, Property): - props[key] = val if isinstance(val, Parameter): params[key] = val - return props, params - - def __str__(self, indent=0): - """ Cast model as a formatted string - """ - try: - ret = '{0:>{2}}{1}'.format('', self.name, indent) - except AttributeError: - ret = "%s" % (type(self)) - if not self._properties: #pragma: no cover - return ret - ret += '\n{0:>{2}}{1}'.format('', 'Parameters:', indent + 2) - width = len(max(self._properties.keys(), key=len)) - for name in self._properties.keys(): - value = getattr(self, name) - par = '{0!s:{width}} : {1!r}'.format(name, value, width=width) - ret += '\n{0:>{2}}{1}'.format('', par, indent + 4) - return ret - - @classmethod - def defaults_docstring(cls, header=None, indent=None, footer=None): - """Add the default values to the class docstring""" - return model_docstring(cls, header=header, indent=indent, footer=footer) #pragma: no cover - - def getp(self, name): - """ - Get the named `Property`. - - Parameters - ---------- - name : str - The property name. - - Returns - ------- - param : `Property` - The parameter object. - """ - return self._properties[name] - - def setp(self, name, **kwargs): - """ - Set the value (and properties) of the named parameter. - - Parameters - ---------- - name : str - The parameter name. + return params - Keywords - -------- - clear_derived : bool - Flag to clear derived objects in this model - - Notes - ----- - The other keywords are passed to the Property.__set__() function - """ - kwcopy = kwargs.copy() - if 'value' in kwcopy: - value = kwcopy.pop('value') - setattr(self, name, value) - if kwcopy: - print(kwcopy) - setattr(self, name, kwcopy) - self._cache(name) - - def set_attributes(self, **kwargs): - """ - Set a group of attributes (parameters and members). Calls - `setp` directly, so kwargs can include more than just the - parameter value (e.g., bounds, free, etc.). - """ - kwargs = dict(kwargs) - for name, value in kwargs.items(): - # Raise AttributeError if param not found - try: - self.getp(name) - except KeyError as msg: - raise KeyError("Warning: %s does not have attribute %s" % - (type(self), name)) from msg - # Set attributes - self.setp(name, value=value) - - def _init_properties(self, **kwargs): - """ Loop through the list of Properties, - extract the derived and required properties and do the - appropriate book-keeping - """ - missing = {} - kwcopy = kwargs.copy() - for k, p in self._properties.items(): - if k not in kwcopy and p.required: - missing[k] = p - pval = kwcopy.pop(k, p.default) - p.__set__(self, pval) - if isinstance(p, Derived): - if p.loader is None: - p.loader = self.__getattribute__("_load_%s" % k) - elif isinstance(p.loader, str): - p.loader = self.__getattribute__(p.loader) - if not callable(p.loader): - raise ValueError("Callabe loader not defined for Derived object", type(self), k, p.loader) - if missing: - raise ValueError("One or more required properties are missing ", type(self), missing.keys()) - if kwcopy: - raise KeyError("Warning: %s does not have attributes %s" % (type(self), kwcopy)) def get_params(self, pnames=None): """ Return a list of Parameter objects @@ -249,143 +89,26 @@ def param_values(self, pnames=None): v = [p.__get__(self) for p in l] return np.array(v) - def param_errors(self, pnames=None): - """ Return an array with the parameter errors - Parameters - ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names - - If none, get all the Parameter objects - - Returns - ------- - ~numpy.array of parameter errors - - Note that this is a N x 2 array. - """ - l = self.get_params(pnames) - v = [getattr(self, p.errors_name) for p in l] - return np.array(v) - - def param_sym_errors(self, pnames=None): - """ Return an array with the parameter errors + def param_str(self, pnames=None): + """ Return an string with the parameter values Parameters ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names - - If none, get all the Parameter objects - Returns - ------- - ~numpy.array of parameter errors - - Note that this is a N x 2 array. - """ - l = self.get_params(pnames) - v = [p.symmetric_error(self) for p in l] - return np.array(v) - - def param_bounds(self, pnames=None): - """ Return an array with the parameter bounds - - Parameters - ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names - - If none, get all the Parameter objects - - Returns - ------- - ~numpy.array of parameter errors - - Note that this is a N x 2 array. - """ - l = self.get_params(pnames) - v = [getattr(self, p.bounds_name) for p in l] - return np.array(v) - - def param_scales(self, pnames=None): - """ Return an array with the parameter bounds - - Parameters - ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names - - If none, get all the Parameter objects - - Returns - ------- - ~numpy.array of parameter scale factors - """ - l = self.get_params(pnames) - v = [getattr(self, p.scale_name) for p in l] - return np.array(v) - - def param_free(self, pnames=None): - """ Return an array with the parameter bounds - - Parameters - ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names + pname : list or None + If a list, get the values of the `Parameter` objects with those names - If none, get all the Parameter objects + If none, get all values of all the `Parameter` objects Returns ------- - ~numpy.array of parameter free flags - """ - l = self.get_params(pnames) - v = [getattr(self, p.free_name) for p in l] - return np.array(v) - - def param_tostr(self, pnames=None): - """ Return an array with the parameter bounds - - Parameters - ---------- - pname : list of string or none - If a list of strings, get the Parameter objects with those names - - If none, get all the Parameter objects - Returns - ------- - ~numpy.array of parameter free flags + s : `str` + Parameter value string """ l = self.get_params(pnames) s = "" for p in l: - s += p.public_name - s += ": x" - s += p.tostr(self) - s += "\n" + s += "%s : %s\n" % (p.public_name, p.tostr(self)) return s - - def todict(self): - """ Return self cast as an '~collections.OrderedDict' object - """ - return odict([(key, val.todict(self)) for key, val in self._properties.items()]) - - def _cache(self, name=None): - """ - Method called in `setp` to cache any computationally - intensive properties after updating the parameters. - - Parameters - ---------- - name : string - The parameter name. - - Returns - ------- - None - """ - #pylint: disable=unused-argument, no-self-use - return diff --git a/python/cfgmdl/param_holder.py b/python/cfgmdl/param_holder.py new file mode 100644 index 0000000..86e0e37 --- /dev/null +++ b/python/cfgmdl/param_holder.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +""" Tools to manage Property object that can be used as fit parameters. +""" +from collections.abc import Mapping +import numpy as np + +from .array import Array +from .configurable import Configurable + +class ParamHolder(Configurable): + """Wrapper around a data value + + This includes value, bounds, error estimates and fixed/free status + (i.e., for fitting) + + """ + value = Array(help='Parameter Value') + errors = Array(help='Parameter Uncertainties') + bounds = Array(help='Parameter Bounds') + scale = Array(default=1., help='Paramter Scale Factor') + free = Array(dtype=bool, default=False, help='Free/Fixed Flag') + + def __init__(self, *args, **kwargs): + """Constructor""" + kwcopy = kwargs.copy() + if args: #pragma: no cover + if 'value' in kwcopy: + raise ValueError("value keyword provided in addition to arguments") + kwcopy['value'] = args + super(ParamHolder, self).__init__(**kwargs) + self.check_bounds() + + def __str__(self, indent=0): + """Return self as a string""" + return '{0:>{2}}{1}'.format('', '', indent) + "{_value} += {_errors} [{_bounds}] <{_scale}> {_free}".format(**self.__dict__) + + def update(self, *args, **kwargs): + """Update the parameter""" + kwcopy = kwargs.copy() + if args: + if 'value' in kwcopy: + raise ValueError("value keyword provided in addition to arguments") + if len(args) > 1: + raise ValueError("Only 1 argument allowed") + if isinstance(args[0], Mapping): + kwcopy.update(**args[0]) + else: + kwcopy['value'] = args[0] + super(ParamHolder, self).update(**kwcopy) + self.check_bounds() + + def check_bounds(self): + """Hook for bounds-checking, invoked during assignment. + + raises ValueError if value is outside of bounds. + does nothing if bounds is set to None. + """ + if np.isnan(self.value).all(): + return + if np.isnan(self.bounds).all(): + return + if np.bitwise_or(self.value < self.bounds[0], self.value > self.bounds[-1]).any(): #pylint: disable=unsubscriptable-object + raise ValueError("Value outside bounds: %.s [%s,%s]" % (self.value, self.bounds[0], self.bounds[-1])) #pylint: disable=unsubscriptable-object + + def __call__(self): + """Return the product of the value and the scale""" + return self.value * self.scale diff --git a/python/cfgmdl/parameter.py b/python/cfgmdl/parameter.py index 1a61571..14b4dfc 100644 --- a/python/cfgmdl/parameter.py +++ b/python/cfgmdl/parameter.py @@ -2,12 +2,14 @@ """ Tools to manage Property object that can be used as fit parameters. """ from copy import deepcopy -from collections import OrderedDict as odict - -from .property import Property, defaults_decorator +from collections.abc import Mapping import numpy as np +from .utils import defaults_decorator +from .property import Property +from .param_holder import ParamHolder + class Parameter(Property): """Property sub-class for defining a numerical Parameter. @@ -18,34 +20,24 @@ class Parameter(Property): """ # Better to keep the structure consistent with Property - defaults = deepcopy(Property.defaults) + [ - ('bounds', np.nan, 'Allowed bounds for value'), - ('errors', np.nan, 'Errors on this parameter'), - ('free', False, 'Is this property allowed to vary?'), - ('scale', 1.0, 'Scale to apply for this property'), - ] + defaults = deepcopy(Property.defaults) + defaults['bounds'] = (np.nan, 'Allowed bounds for value') + defaults['errors'] = (np.nan, 'Errors on this parameter') + defaults['free'] = (False, 'Is this property allowed to vary?') + defaults['scale'] = (1.0, 'Scale to apply for this property') + # Overwrite the default dtype - idx = [d[0] for d in defaults].index('dtype') - defaults[idx] = ('dtype', np.ndarray, 'Data type') - idx = [d[0] for d in defaults].index('default') - defaults[idx] = ('default', np.nan, 'Default value') + defaults['dtype'] = (ParamHolder, 'Data type') + defaults['default'] = (np.nan, 'Default value') @defaults_decorator(defaults) def __init__(self, **kwargs): - self.bounds_name = None - self.errors_name = None - self.free_name = None - self.scale_name = None + self.bounds = np.nan + self.errors = np.nan + self.free = False + self.scale = 1. super(Parameter, self).__init__(**kwargs) - def __set_name__(self, owner, name): - """Set the name of the privately managed value""" - super(Parameter, self).__set_name__(owner, name) - self.bounds_name = '_' + name + '_bounds' - self.errors_name = '_' + name + '_errors' - self.free_name = '_' + name + '_free' - self.scale_name = '_' + name + '_scale' - def __set__(self, obj, value): """Set the value in the client object @@ -68,83 +60,48 @@ def __set__(self, obj, value): If value is a dict, this will use `Darray(**value)` to construct the managed value Otherwise this will use Darray(value, **defaults) to construct the managed value """ - if not isinstance(value, dict): - value = dict(value=value) - else: - value.setdefault('value', getattr(obj, self.private_name, None)) + par = getattr(obj, self.private_name, None) - for key in ['bounds', 'errors', 'scale', 'free']: - hidden_name = "_%s_%s" % (self.public_name, key) - if not hasattr(obj, hidden_name): - cast_val = getattr(self, key) + if par is None: + if isinstance(value, Mapping): + par = self.dtype(**value) else: - if key not in value: - continue - try: - if key in ['free']: - cast_val = np.array(value[key]).astype(bool) - else: - cast_val = np.array(value[key]).astype(float) - except ValueError as msg: - raise ValueError("Failed to set %s: not %s castable as array" % (hidden_name, value[key])) from msg - setattr(obj, hidden_name, cast_val) - - super(Parameter, self).__set__(obj, value['value']) + par = self.dtype(value=value, bounds=self.bounds, errors=self.errors, scale=self.scale, free=self.free) + else: + par.update(value) + super(Parameter, self).__set__(obj, par) - def validate_value(self, obj, value): - """Validate a value + def __get__(self, obj, objtype=None): + """Get the value from the client object - In this case this does type-checking and bounds-checking + Parameter + --------- + obj : ... + The client object - Rasies + Return ------ - TypeError : The input value is the wrong type (i.e., not float or float64) - - ValueError : The input values fail the bounds check + out : ... + The requested value """ - self.check_bounds(obj, value) + val = getattr(obj, self.private_name).__call__() + if self.unit is None: + return val + return self.unit(val) #pylint: disable=not-callable def todict(self, obj): """Extract values as an odict """ - return odict([('value', np.array(getattr(obj, self.private_name)))] + - [(key, getattr(obj, "_%s_%s" % (self.public_name, key), None)) for key in ['bounds', 'errors', 'free', 'scale']]) + return getattr(obj, self.private_name).todict() def tostr(self, obj): """Extract values as a string""" - ret = str(getattr(obj, self.private_name)) - errors = getattr(obj, self.errors_name, None) - bounds = getattr(obj, self.bounds_name, None) - scale = getattr(obj, self.scale_name, None) - free = getattr(obj, self.free_name, None) - - ret += ' +- %s' % errors - ret += ' [%s]' % bounds - ret += ' <%s>' % scale - ret += ' %s' % free - return ret - - def symmetric_error(self, obj): - """Return the symmertic error - """ - errors = getattr(obj, self.errors_name, None) - errors = np.array(errors) - if not errors.shape: - return errors - if errors.shape[0] == 2: - return 0.5 * (errors[0] + errors[1]) - return errors - - def check_bounds(self, obj, value): - """Bounds checking""" - bounds = getattr(obj, self.bounds_name) - if np.isnan(bounds).all(): - return - if np.any(value < bounds[0]) or np.any(value > bounds[1]): - msg = "Value outside bounds: %.2g [%.2g,%.2g]" % (value, bounds[0], bounds[1]) - raise ValueError(msg) - + return str(getattr(obj, self.private_name)) def _cast_type(self, value): """Hook took override type casting""" - return np.array(value).astype(float) + return value + + def validate_value(self, obj, value): #pylint: disable=unused-argument,no-self-use + """Validate a value""" + value.check_bounds() diff --git a/python/cfgmdl/property.py b/python/cfgmdl/property.py index 1c296e4..9d6d922 100644 --- a/python/cfgmdl/property.py +++ b/python/cfgmdl/property.py @@ -3,7 +3,11 @@ """ import time -from .utils import cast_type, Meta, defaults_decorator, defaults_docstring + +from collections import OrderedDict as odict + + +from .utils import cast_type, Meta, Defs, defaults_decorator, defaults_docstring try: basestring @@ -12,7 +16,7 @@ -class Property: +class Property(Defs): """Base class to attach managed attribute to class. Notes @@ -30,13 +34,14 @@ class Property: """ __metaclass__ = Meta - defaults = [ - ('help', "", 'Help description'), - ('format', '%s', 'Format string for printing'), - ('dtype', None, 'Data type'), - ('default', None, 'Default value'), - ('required', False, 'Is this propery required?'), - ] + defaults = odict([ + ('help', ("", 'Help description')), + ('format', ('%s', 'Format string for printing')), + ('dtype', (None, 'Data type')), + ('default', (None, 'Default value')), + ('required', (False, 'Is this propery required?')), + ('unit', (None, 'Units for unit')), + ]) @defaults_decorator(defaults) def __init__(self, **kwargs): @@ -45,9 +50,11 @@ def __init__(self, **kwargs): self.dtype = type(None) self.default = None self.required = None + self.unit = None self.public_name = None self.private_name = None self.time_name = None + super(Property, self).__init__() self._load(**kwargs) def __set_name__(self, owner, name): @@ -64,8 +71,7 @@ def __set__(self, obj, value): obj : ... The client object value : ... - The value being set - + The value being seti This will use the `cast_type(self.dtype, value)` method to cast the requested value to the correct type. Rasies @@ -97,7 +103,10 @@ def __get__(self, obj, objtype=None): out : ... The requested value """ - return getattr(obj, self.private_name) + attr = getattr(obj, self.private_name) + if self.unit is None: + return attr + return self.unit(attr) #pylint: disable=not-callable def __delete__(self, obj): """Set the value to the default value @@ -111,10 +120,10 @@ def __delete__(self, obj): def _load(self, **kwargs): """Load kwargs key,value pairs into __dict__ """ - defaults = {d[0]:d[1] for d in self.defaults} + defaults = {} # Require kwargs are in defaults for k in kwargs: - if k not in defaults: + if k not in self.defaults: msg = "Unrecognized attribute of %s: %s" % (self.__class__.__name__, k) raise AttributeError(msg) @@ -126,6 +135,7 @@ def _load(self, **kwargs): # Make sure the default is valid _ = self._cast_type(self.default) + def _cast_type(self, value): """Hook took override type casting""" return cast_type(self.dtype, value) diff --git a/python/cfgmdl/unit.py b/python/cfgmdl/unit.py new file mode 100644 index 0000000..536c9d5 --- /dev/null +++ b/python/cfgmdl/unit.py @@ -0,0 +1,44 @@ +"""Small module for unit converision""" + +import numpy as np + +class Unit: + """ + Object for handling unit conversions + + """ + to_SI_dict = {} + + def __init__(self, unit=None): + # Dictionary of SI unit conversions + # Check that passed unit is available + if unit is None: + self._SI = 1. + self._name = '' + return + if isinstance(unit, str): + if unit not in self.to_SI_dict: + raise KeyError("Passed unit '%s' not understood by Unit object" % (unit)) + self._SI = self.to_SI_dict[unit] + self._name = unit + return + self._SI = float(unit) + self._name = "a.u." + + @property + def name(self): + """Return the units name""" + return self._name + + def __call__(self, val): + """Convert value to SI unit """ + return np.array(val) * self._SI + + def inverse(self, val): + """Convert value from SI unit """ + return np.array(val) / self._SI + + @classmethod + def update(cls, a_dict): + """Update the mapping of unit names""" + cls.to_SI_dict.update(a_dict) diff --git a/python/cfgmdl/utils.py b/python/cfgmdl/utils.py index cb27eb2..49def34 100644 --- a/python/cfgmdl/utils.py +++ b/python/cfgmdl/utils.py @@ -12,6 +12,10 @@ basestring = str + + + + def defaults_docstring(defaults, header='', indent='', footer=''): """Return a docstring from a list of defaults. """ @@ -21,7 +25,7 @@ def defaults_docstring(defaults, header='', indent='', footer=''): hbar = '\n' s = hbar + (header) + hbar - for key, value, desc in defaults: + for key, (value, desc) in defaults.items(): if isinstance(value, basestring): value = "'" + value + "'" if hasattr(value, '__call__'): @@ -86,6 +90,29 @@ def __doc__(cls): return cls._doc + cls.defaults_docstring(**kwargs) +class Defs: + """ Mixin class to handle default value construction""" + __metaclass__ = Meta + + defaults = odict() + + def __init__(self, default_prefix=''): + """Load kwargs key,value pairs into __dict__ + """ + self._default_prefix = default_prefix + for key, val in self.defaults.items(): + self.__dict__["%s%s" % (default_prefix, key)] = val[0] + + @property + def default_prefix(self): + """Get the prefix associated to defaults to make attributes""" + return self._default_prefix + + def default_value(self, name): + """Get the default value of a particular attribute""" + return self.__class__.defaults.get(name)[0] + + def is_none(val): """Check for values equivalent to None diff --git a/tests/test_choice.py b/tests/test_choice.py index 6cd3414..99bea80 100644 --- a/tests/test_choice.py +++ b/tests/test_choice.py @@ -43,5 +43,5 @@ class TestClass(Model): except (TypeError, ValueError): pass else: raise TypeError("Failed to catch ValueError in Choice") - help(test_obj.getp('vv')) + help(test_obj._vv) #test_obj.getp('vv').dump() diff --git a/tests/test_function.py b/tests/test_function.py index a941e0c..5be8725 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -38,6 +38,8 @@ def test_function(): assert a.ff.grad(1., 1.) == 6. assert a.ff.hess(1., 1.) == 0. + assert np.isnan(a.ff(None, 1.)) + try: a.ff(1.) except AttributeError: pass else: raise AttributeError("Failed to catch AttributeError in Function.get_args()") diff --git a/tests/test_model.py b/tests/test_model.py index 9ed335f..0356156 100755 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -48,15 +48,15 @@ def _load_der(self): b.z = 100. assert b.z == 100. - b.setp('z', value=40.) + b.update(z=40.) assert b.z == 40. - b.setp('z', free=True) - assert b.param_free(['z']) + b.update(z=dict(free=True)) + assert b._z.free - try: b.setp('z', bounds="aa") - except ValueError: pass - else: raise ValueError("Failed to catch TypeError in Parameter.__set__") + try: b.update(z=dict(bounds="aa")) + except TypeError: pass + else: raise TypeError("Failed to catch TypeError in Parameter.__set__") t = test_class(req=2.,var=2.) @@ -72,28 +72,26 @@ def _load_der(self): assert t_copy.opt == t.opt dd['var'] = 1.5 - t_copy.set_attributes(**dd) + t_copy.update(**dd) assert t_copy.var == 1.5 - try: t_copy.set_attributes(aa=15.) + try: t_copy.update(aa=15.) except KeyError: pass - else: raise KeyError("Failed to catch AttributeError in Model.set_attributes") - - try: t_copy.set_attributes(var=15.) - except TypeError: pass - else: raise TypeError("Failed to catch ValueError in Model.set_attributes") - - assert t.param_sym_errors(['var']) == 0.2 - assert np.isnan(t.param_sym_errors(['var2'])) + else: raise KeyError("Failed to catch KeyError in Model.update") - t.setp('var', errors=0.1) - assert np.allclose(t.param_errors(['var']), 0.1) - assert np.allclose(t.param_sym_errors(['var']), 0.1) + try: t_copy.update(15.) + except ValueError: pass + else: raise ValueError("Failed to catch ValueError in Model.update") + + try: t_copy.update(var=15.) + except ValueError: pass + else: raise ValueError("Failed to catch ValueError in Model.update %s" % t_copy._var) - t.setp('var', errors=[0.1, 0.2, 0.3]) - assert np.allclose(t.param_errors(['var']), [0.1, 0.2, 0.3]) - assert np.allclose(t.param_sym_errors(['var']), [0.1, 0.2, 0.3]) + t.update(var=dict(errors=0.1)) + assert np.allclose(t._var.errors, 0.1) + t.update(var=dict(errors=[0.1, 0.2, 0.3])) + assert np.allclose(t._var.errors, [0.1, 0.2, 0.3]) test_val = t.req * t.opt * t.var check = t.der @@ -128,55 +126,30 @@ def _load_der(self): assert len(vals) == 1 assert vals[0] == 3. - errs = a.param_errors() - assert len(errs) == 2 - assert np.isnan(errs[0]).all() - - errs = a.param_errors(['x']) - assert len(errs) == 1 - assert np.isnan(errs[0]).all() - - bounds = a.param_bounds() - assert len(bounds) == 2 - assert np.isnan(bounds[0]).all() + assert np.isnan(a._x.errors) - bounds = a.param_bounds(['x']) - assert len(bounds) == 1 - assert np.isnan(bounds[0]).all() - - scales = a.param_scales() - assert len(scales) == 2 - assert np.allclose(scales, 1) + assert np.isnan(a._x.bounds) - scales = a.param_scales(['x']) - assert len(scales) == 1 - assert np.allclose(scales, 1) - - free = a.param_free() - assert len(free) == 2 - assert not free.any() + assert np.allclose(a._x.scale, 1) - free = a.param_free(['x']) - assert len(free) == 1 - assert not free.any() + assert not a._x.free - a_dict = a.todict() a_str = str(a) a_yaml = yaml.dump(a_dict) - - a_p_str = a.param_tostr() - + a_pstr = a.param_str() + print(a_pstr) + for key in ['x', 'y']: assert key in a_dict assert a_str.index(key) >= 0 assert a_yaml.index(key) >= 0 - assert a_p_str.index(key) >= 0 - + assert a_pstr.index(key) >= 0 + try: a.x = 'afda' except TypeError: pass - else: raise TypeError("Failed to catch TypeError in Model.setp") + else: raise TypeError("Failed to catch TypeError in Model.__set__") aa = Child(x=2.) aa.x == 2 @@ -186,7 +159,7 @@ def _load_der(self): try: bad = test_class(req="aa") except TypeError: pass - else: raise TypeError("Failed to catch TypeError in Model.set_attributes") + else: raise TypeError("Failed to catch TypeError in Model.update") try: bad = Child(vv=dict(value=3)) except KeyError: pass @@ -208,7 +181,7 @@ def _load_der(self): class TestClass(Model): p1 = Property(dtype=Inner, default=Inner()) p2 = Property(dtype=Inner, default=Inner()) - + px = Parameter() der = Derived(dtype=float, format='%.1f', help="A derived parameter") def _load_der(self): @@ -247,3 +220,8 @@ def assert_vals(test_obj, check_vals): assert np.allclose(extract_vals(test_obj), extract_vals(test_copy)) + test_obj.update(px=3.3) + assert test_obj.px == 3.3 + + test_obj.update(px=[3.3, 3.4]) + assert np.allclose(test_obj.px, [3.3, 3.4]) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 88a8c2d..19a430a 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -3,10 +3,11 @@ Test the model build """ import numpy as np +from collections import OrderedDict as odict -from cfgmdl import Model, Parameter +from cfgmdl import Model, Parameter, Unit +Unit.update(dict(mm=1e-3)) -from collections import OrderedDict as odict def test_parameter(): try: @@ -20,18 +21,22 @@ class TestClass(Model): test_obj = TestClass() assert np.isnan(test_obj.vv) + + import pdb + #pdb.set_trace() test_obj.vv = 0.3 assert test_obj.vv == 0.3 test_obj.vv = [0.3, 0.2, 0.4] assert np.allclose(test_obj.vv, [0.3, 0.2, 0.4]) - help(test_obj.getp('vv')) - #test_obj.getp('vv').dump() + help(test_obj._vv) class TestClass(Model): vv = Parameter(default=0.3) + vv2 = Parameter(default=0.3, unit=Unit('mm')) test_obj = TestClass() + assert test_obj.vv == 0.3 test_obj.vv = 0.4 assert test_obj.vv == 0.4 @@ -41,8 +46,26 @@ class TestClass(Model): test_obj.vv += 0.1 assert np.allclose(test_obj.vv, [0.4, 0.3, 0.5]) + assert test_obj.vv2 == Unit('mm')(0.3) + + try: test_obj._vv.update(3.3, value=3.3) + except ValueError: pass + else: raise ValueError("Failed to catch value error") + + try: test_obj._vv.update(3.3, 5.3) + except ValueError: pass + else: raise ValueError("Failed to catch value error") + + test_obj._vv.update(dict(value=3.3)) + assert test_obj.vv == 3.3 + test_obj = TestClass(vv=[0.3, 0.2, 0.4]) assert np.allclose(test_obj.vv, [0.3, 0.2, 0.4]) test_obj.vv += 0.1 assert np.allclose(test_obj.vv, [0.4, 0.3, 0.5]) + + class TestClass(Model): + vv = Parameter(default=(3.3)) + test_obj = TestClass(vv=3.5) + assert test_obj.vv == 3.5 diff --git a/tests/test_property.py b/tests/test_property.py index e5adaab..c6d2556 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -2,17 +2,23 @@ """ Test the model build """ +import numpy as np + from cfgmdl import Model, Property from collections import OrderedDict as odict +from cfgmdl import Unit +Unit.update(dict(mm=1e-3)) + -def check_property(dtype, default, test_val, bad_val=None, cast_val=None): +def check_property(dtype, default, test_val, bad_val=None, cast_val=None, test_unit=None): class TestClass(Model): v = Property(dtype=dtype, default=default, help="A Property") v2 = Property(dtype=dtype, help="A Property") v3 = Property(dtype=dtype, required=True, help="A Property") + v4 = Property(dtype=dtype, default=test_val, help="A Property", unit=test_unit) try: bad = TestClass() except ValueError: pass @@ -21,8 +27,16 @@ class TestClass(Model): test_obj = TestClass(v3=default) assert test_obj.v == default assert test_obj.v2 is None - assert test_obj.v3 == default + if test_unit: + assert np.allclose(test_obj.v4, test_unit(test_val)) + else: + assert test_obj.v4 == test_val + + assert test_obj._properties['v'].default_prefix == "" + assert test_obj._properties['v'].default_value('dtype') is None + + test_obj.v = test_val assert test_obj.v == test_val @@ -97,8 +111,9 @@ class TestClass(Model): test_obj = TestClass() - help(test_obj.getp('vv')) + help(test_obj._vv) + def test_property_none(): check_property(None, None, None) @@ -106,13 +121,13 @@ def test_property_string(): check_property(str, "aa", "ab") def test_property_int(): - check_property(int, 1, 2, "aa") + check_property(int, 1, 2, "aa", test_unit=Unit('mm')) def test_property_float(): - check_property(float, 1., 2., "aa", 1) + check_property(float, 1., 2., "aa", 1, test_unit=Unit('mm')) def test_property_list(): - check_property(list, [], [3, 4], None, (3, 4)) + check_property(list, [], [3, 4], None, (3, 4), test_unit=Unit('mm')) def test_property_dict(): check_property(dict, {}, {3:4}) diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..4372e20 --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +Test the model build +""" +import numpy as np + +from cfgmdl.unit import Unit + + +def test_units(): + + Unit.update(dict(mm=1e-3)) + + one = Unit() + assert one.name == '' + assert one(1.) == 1. + + mm = Unit('mm') + assert mm.name == 'mm' + assert mm(1.) == 1e-3 + assert mm.inverse(1.) == 1e3 + + au = Unit(1e-3) + assert au.name == 'a.u.' + assert au(1.) == 1e-3 + assert au.inverse(1.) == 1e3 + + try: Unit("ff") + except KeyError: pass + else: raise KeyError("Failed to catch KeyError") + + try: Unit([1, 2, 3]) + except TypeError: pass + else: raise TypeError("Failed to catch TypeError")