"""math.py: vectormath property classes"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import numpy as np
from six import integer_types, string_types
import vectormath as vmath
from .basic import Property, TOL
from .utils import ValidationError
TYPE_MAPPINGS = {
int: 'i',
float: 'f',
bool: 'b',
complex: 'c',
}
[docs]class Array(Property):
"""Property for :class:`numpy arrays <numpy.ndarray>`
**Available keywords** (in addition to those inherited from
:ref:`Property <property>`):
* **shape** - Tuple (or set of valid tuples) that describes the
allowed shape of the array. Length of shape tuple corresponds to
number of dimensions; values correspond to the allowed length for
each dimension. These values may be integers or '*' for any length.
For example, an n x 3 array would be shape ('*', 3). None may also
be used if any shape is valid.
The default value is ('*',).
* **dtype** - Allowed data type for the array. May be float, int,
bool, or a tuple containing any of these. The default is (float, int).
"""
class_info = 'a list or numpy array'
@property
def wrapper(self):
"""Class used to wrap the value in the validation call.
For the base Array class, this is a :func:`numpy.array` but
subclasses can use other wrappers such as :class:`tuple`,
:class:`list` or :class:`vectormath.vector.Vector3`
"""
return np.array
@property
def shape(self):
"""Valid array shape.
Must be a tuple with integer or '*' entries corresponding to valid
array shapes. '*' means the dimension can be any length. A set of
these tuples may also be provided if multiple shapes are valid.
If any shape is valid, use None for shape.
"""
return getattr(self, '_shape', {('*',)})
@shape.setter
def shape(self, value):
if value is None:
self._shape = value
return
self._shape = self._validate_shape(value)
@staticmethod
def _validate_shape(value):
if not isinstance(value, set):
try:
value = {value}
except TypeError:
# Valid shapes are hashable - we are just deferring errors
value = [value]
for val in value:
if not isinstance(val, tuple):
raise TypeError("{}: Invalid shape - must be a tuple "
"(e.g. ('*', 3) for an array of length-3 "
"arrays)".format(val))
for shp in val:
if shp != '*' and not isinstance(shp, integer_types):
raise TypeError("{}: Invalid shape - values "
"must be '*' or int".format(val))
return value
@property
def dtype(self):
"""Valid type of the array
May be float, int, bool or a tuple of any of these
"""
return getattr(self, '_dtype', (float, int))
@dtype.setter
def dtype(self, value):
if not isinstance(value, (list, tuple)):
value = (value,)
if len(value) == 0: #pylint: disable=len-as-condition
raise TypeError('No dtype specified - must be int, float, '
'and/or bool')
if any([val not in TYPE_MAPPINGS for val in value]):
raise TypeError('{}: Invalid dtype - must be {}'.format(
value, ', '.join(v.__name__ for v in TYPE_MAPPINGS)
))
self._dtype = value
@property
def info(self):
if self.shape is None:
shape_info = 'any shape'
else:
shape_info = 'shape {}'.format(' or '.join(
'({})'.format(', '.join(
'\*' if s == '*' else str(s) for s in shape #pylint: disable=anomalous-backslash-in-string
)) for shape in self.shape
))
return '{info} of {type} with {shp}'.format(
info=self.class_info,
type=', '.join([str(t) for t in self.dtype]),
shp=shape_info,
)
def validate(self, instance, value):
"""Determine if array is valid based on shape and dtype"""
if not isinstance(value, (tuple, list, np.ndarray)):
self.error(instance, value)
value = self.wrapper(value)
if not isinstance(value, np.ndarray):
raise NotImplementedError(
'Array validation is only implmented for wrappers that are '
'subclasses of numpy.ndarray'
)
if value.dtype.kind not in (TYPE_MAPPINGS[typ] for typ in self.dtype):
self.error(instance, value)
if self.shape is None:
return value
for shape in self.shape:
if len(shape) != value.ndim:
continue
for i, shp in enumerate(shape):
if shp not in ('*', value.shape[i]):
break
else:
return value
self.error(instance, value)
def equal(self, value_a, value_b):
try:
if value_a.__class__ is not value_b.__class__:
return False
nan_mask = ~np.isnan(value_a)
if not np.array_equal(nan_mask, ~np.isnan(value_b)):
return False
return np.allclose(value_a[nan_mask], value_b[nan_mask], atol=TOL)
except TypeError:
return False
def error(self, instance, value, error_class=None, extra=''):
"""Generates a ValueError on setting property to an invalid value"""
error_class = error_class or ValidationError
if not isinstance(value, (list, tuple, np.ndarray)):
super(Array, self).error(instance, value, error_class, extra)
if isinstance(value, (list, tuple)):
val_description = 'A {typ} of length {len}'.format(
typ=value.__class__.__name__,
len=len(value)
)
else:
val_description = 'An array of shape {shp} and dtype {typ}'.format(
shp=value.shape,
typ=value.dtype
)
if instance is None:
prefix = '{} property'.format(self.__class__.__name__)
else:
prefix = "The '{name}' property of a {cls} instance".format(
name=self.name,
cls=instance.__class__.__name__,
)
message = (
'{prefix} must be {info}. {desc} was specified. {extra}'.format(
prefix=prefix,
info=self.info,
desc=val_description,
extra=extra,
)
)
if issubclass(error_class, ValidationError):
raise error_class(message, 'invalid', self.name, instance)
raise error_class(message)
def deserialize(self, value, **kwargs):
"""De-serialize the property value from JSON
If no deserializer has been registered, this converts the value
to the wrapper class with given dtype.
"""
kwargs.update({'trusted': kwargs.get('trusted', False)})
if self.deserializer is not None:
return self.deserializer(value, **kwargs)
if value is None:
return None
return self.wrapper(value).astype(self.dtype[0])
@staticmethod
def to_json(value, **kwargs):
"""Convert array to JSON list
nan values are converted to string 'nan', inf values to 'inf'.
"""
def _recurse_list(val):
if val and isinstance(val[0], list):
return [_recurse_list(v) for v in val]
return [str(v) if np.isnan(v) or np.isinf(v) else v for v in val]
return _recurse_list(value.tolist())
@staticmethod
def from_json(value, **kwargs):
return np.array(value).astype(float)
class ZeroDivValidationError(ValidationError, ZeroDivisionError): #pylint: disable=too-many-ancestors
"""Exception type for validation errors related to division-by-zero"""
class BaseVector(Array):
"""Base class for Vector properties"""
@property
def dtype(self):
"""Vectors must be floats"""
return (float,)
@property
def length(self):
"""Length to which vectors are scaled
If None, vectors are not scaled
"""
return getattr(self, '_length', None)
@length.setter
def length(self, value):
if not isinstance(value, (float, integer_types)):
raise TypeError('length must be a float')
if value <= 0.0:
raise TypeError('length must be positive')
self._length = float(value)
def _length_array(self, value): #pylint: disable=unused-argument
"""Return scalar length for Vector classes.
This is overridden to return array length for VectorArray classes.
"""
return self.length
def validate(self, instance, value):
"""Check shape and dtype of vector and scales it to given length"""
value = super(BaseVector, self).validate(instance, value)
if self.length is not None:
try:
value.length = self._length_array(value)
except ZeroDivisionError:
self.error(
instance, value,
error_class=ZeroDivValidationError,
extra='The vector must have a length specified.'
)
return value
[docs]class Vector3(BaseVector):
"""Property for :class:`3D vectors<vectormath.vector.Vector3>`
These Vectors are of shape (3,) and dtype float. In addition to
length-3 arrays, these properties accept strings including: 'zero', 'x',
'y', 'z', '-x', '-y', '-z', 'east', 'west', 'north', 'south', 'up',
and 'down'.
**Available keywords** (in addition to those inherited from
:ref:`Property <property>`):
* **length** - On validation, vectors are scaled to this length. If
None (the default), vectors are not scaled
"""
class_info = 'a 3D Vector'
@property
def wrapper(self):
"""Vector3 wrapper: :class:`vectormath.vector.Vector3`"""
return vmath.Vector3
@property
def shape(self):
"""Vector3 is fixed at length-3"""
return {(3,)}
def validate(self, instance, value):
"""Check shape and dtype of vector
validate also coerces the vector from valid strings (these
include ZERO, X, Y, Z, -X, -Y, -Z, EAST, WEST, NORTH, SOUTH, UP,
and DOWN) and scales it to the given length.
"""
if isinstance(value, string_types):
if value.upper() not in VECTOR_DIRECTIONS:
self.error(instance, value)
value = VECTOR_DIRECTIONS[value.upper()]
return super(Vector3, self).validate(instance, value)
@staticmethod
def from_json(value, **kwargs):
return vmath.Vector3(value)
[docs]class Vector2(BaseVector):
"""Property for :class:`2D vectors <vectormath.vector.Vector2>`
These Vectors are of shape (2,) and dtype float. In addition to
length-2 arrays, these properties accept strings including: 'zero', 'x',
'y', '-x', '-y', 'east', 'west', 'north', and 'south'.
**Available keywords** (in addition to those inherited from
:ref:`Property <property>`):
* **length** - On validation, vectors are scaled to this length. If
None (the default), vectors are not scaled
"""
class_info = 'a 2D Vector'
@property
def wrapper(self):
"""Vector2 wrapper: :class:`vectormath.vector.Vector2`"""
return vmath.Vector2
@property
def shape(self):
"""Vector2 is fixed at length-2"""
return {(2,)}
def validate(self, instance, value):
"""Check shape and dtype of vector
validate also coerces the vector from valid strings (these
include ZERO, X, Y, -X, -Y, EAST, WEST, NORTH, and SOUTH) and
scales it to the given length.
"""
if isinstance(value, string_types):
if (
value.upper() not in VECTOR_DIRECTIONS or
value.upper() in ('Z', '-Z', 'UP', 'DOWN')
):
self.error(instance, value)
value = VECTOR_DIRECTIONS[value.upper()][:2]
return super(Vector2, self).validate(instance, value)
@staticmethod
def from_json(value, **kwargs):
return vmath.Vector2(value)
[docs]class Vector3Array(BaseVector):
"""Property for an :class:`array of 3D vectors <vectormath.vector.Vector3Array>`
This array of vectors are of shape ('*', 3) and dtype float. In addition
to an array of this shape, these properties accept a list of strings
including: 'zero', 'x', 'y', 'z', '-x', '-y', '-z', 'east', 'west',
'north', 'south', 'up', and 'down'.
**Available keywords** (in addition to those inherited from
:ref:`Property <property>`):
* **length** - On validation, all vectors are scaled to this length. If
None (the default), vectors are not scaled
"""
class_info = 'a list of Vector3'
@property
def wrapper(self):
"""Vector3Array wrapper: :class:`vectormath.vector.Vector3Array`"""
return vmath.Vector3Array
@property
def shape(self):
"""Vector3Array is shape n x 3"""
return getattr(self, '_shape', {('*', 3)})
@shape.setter
def shape(self, value):
value = self._validate_shape(value)
for val in value:
if len(val) != 2 or val[1] != 3:
raise TypeError('{}: Invalid shape - Vector3Array must '
'have two dimensions, and the second '
'must equal 3'.format(val))
self._shape = value
def _length_array(self, value):
return np.ones(value.shape[0])*self.length
def validate(self, instance, value):
"""Check shape and dtype of vector
validate also coerces the vector from valid strings (these
include ZERO, X, Y, Z, -X, -Y, -Z, EAST, WEST, NORTH, SOUTH, UP,
and DOWN) and scales it to the given length.
"""
if not isinstance(value, (tuple, list, np.ndarray)):
self.error(instance, value)
for i, val in enumerate(value):
if isinstance(val, string_types):
if val.upper() not in VECTOR_DIRECTIONS:
self.error(instance, val)
value[i] = VECTOR_DIRECTIONS[val.upper()]
return super(Vector3Array, self).validate(instance, value)
@staticmethod
def from_json(value, **kwargs):
return vmath.Vector3Array(value)
[docs]class Vector2Array(BaseVector):
"""Property for an :class:`array of 2D vectors <vectormath.vector.Vector2Array>`
This array of vectors are of shape ('*', 2) and dtype float. In addition
to an array of this shape, these properties accept a list of strings
including: 'zero', 'x', 'y', '-x', '-y', 'east', 'west', 'north',
and 'south'.
**Available keywords** (in addition to those inherited from
:ref:`Property <property>`):
* **length** - On validation, all vectors are scaled to this length. If
None (the default), vectors are not scaled
"""
class_info = 'a list of Vector2'
@property
def wrapper(self):
"""Vector2Array wrapper: :class:`vectormath.vector.Vector2Array`"""
return vmath.Vector2Array
@property
def shape(self):
"""Vector2Array is shape n x 2"""
return getattr(self, '_shape', {('*', 2)})
@shape.setter
def shape(self, value):
value = self._validate_shape(value)
for val in value:
if len(val) != 2 or val[1] != 2:
raise TypeError('{}: Invalid shape - Vector2Array must '
'have two dimensions, and the second '
'must equal 2'.format(val))
self._shape = value
def _length_array(self, value):
return np.ones(value.shape[0])*self.length
def validate(self, instance, value):
"""Check shape and dtype of vector
validate also coerces the vector from valid strings (these
include ZERO, X, Y, -X, -Y, EAST, WEST, NORTH, and SOUTH) and
scales it to the given length.
"""
if not isinstance(value, (tuple, list, np.ndarray)):
self.error(instance, value)
if isinstance(value, (tuple, list)):
for i, val in enumerate(value):
if (
isinstance(val, string_types) and
val.upper() in VECTOR_DIRECTIONS and
val.upper() not in ('Z', '-Z', 'UP', 'DOWN')
):
value[i] = VECTOR_DIRECTIONS[val.upper()][:2]
return super(Vector2Array, self).validate(instance, value)
@staticmethod
def from_json(value, **kwargs):
return vmath.Vector2Array(value)
VECTOR_DIRECTIONS = {
'ZERO': [0, 0, 0],
'X': [1, 0, 0],
'Y': [0, 1, 0],
'Z': [0, 0, 1],
'-X': [-1, 0, 0],
'-Y': [0, -1, 0],
'-Z': [0, 0, -1],
'EAST': [1, 0, 0],
'WEST': [-1, 0, 0],
'NORTH': [0, 1, 0],
'SOUTH': [0, -1, 0],
'UP': [0, 0, 1],
'DOWN': [0, 0, -1],
}