diff --git a/README.md b/README.md index c43fed6..c37852a 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,21 @@ Other modules: - [blmath.geometry.segment](segment.py) provides functions for working with line segments in n-space. +blmath.value +------------ +Class for wrapping and manipulating `value`/`units` pairs. + blmath.units ------------ TODO write something here +blmath.console +------------ +- [blmath.console.input_float](console.py) reads and returns a float from console. +- [blmath.console.input_value](console.py) combines `units` with a float input from console + and returns `Value` object. + + Development ----------- diff --git a/blmath/__init__.py b/blmath/__init__.py index 3f6fab6..1a72d32 100644 --- a/blmath/__init__.py +++ b/blmath/__init__.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.1.0' diff --git a/blmath/console.py b/blmath/console.py new file mode 100644 index 0000000..64253e8 --- /dev/null +++ b/blmath/console.py @@ -0,0 +1,37 @@ + + +def input_float(prompt, allow_empty=False): + ''' + Read a float from the console, showing the given prompt. + + prompt: The prompt message. + allow_empty: When `True`, allows an empty input. The default is to repeat + until a valid float is entered. + ''' + from plumbum.cli import terminal + if allow_empty: + return terminal.prompt(prompt, type=float, default=None) + else: + return terminal.prompt(prompt, type=float) + +def input_value(label, units, allow_empty=False): + ''' + Read a value from the console, and return an instance of `Value`. The + units are specified by the caller, but displayed to the user. + + label: A label for the value (included in the prompt) + units: The units (included in the prompt) + allow_empty: When `True`, allows an empty input. The default is to repeat + until a valid float is entered. + ''' + from blmath.value import Value + + value = input_float( + prompt='{} ({}): '.format(label, units), + allow_empty=allow_empty + ) + + if value is None: + return None + + return Value(value, units) diff --git a/blmath/test_value.py b/blmath/test_value.py new file mode 100644 index 0000000..684cc50 --- /dev/null +++ b/blmath/test_value.py @@ -0,0 +1,182 @@ +import unittest +import numpy as np +from blmath.value import Value +from blmath.util import json + +class TestValueClass(unittest.TestCase): + def test_value_initializes_correctly(self): + with self.assertRaises(TypeError): + _ = Value() # It's a failure test. pylint: disable=no-value-for-parameter + with self.assertRaises(ValueError): + _ = Value(13, 'mugglemeters') + with self.assertRaises(ValueError): + _ = Value(None, 'mugglemeters') + with self.assertRaises(ValueError): + _ = Value(None, 'mm') + + x = Value(13, 'mm') + self.assertIsInstance(x, Value) + self.assertFalse(isinstance(x, float)) + self.assertTrue(hasattr(x, 'units')) + self.assertEqual(x.units, 'mm') + + def test_value_initializes_with_unitless_values(self): + _ = Value(1, None) + + def test_exception_raised_if_value_is_nonsense(self): + with self.assertRaises(ValueError): + Value('x', 'mm') + v = Value(0, 'mm') + with self.assertRaises(ValueError): + v.value = 'x' + + def test_conversions(self): + x = Value(25, 'cm') + self.assertAlmostEqual(x.convert('m'), 0.25) + self.assertAlmostEqual(x.convert('cm'), 25) + self.assertAlmostEqual(x.convert('mm'), 250) + self.assertAlmostEqual(x.convert('in'), 9.8425197) # from google + self.assertAlmostEqual(x.convert('ft'), 0.82021) # from google + self.assertAlmostEqual(x.convert('fathoms'), 0.136701662) # from google + self.assertAlmostEqual(x.convert('cubits'), 0.546806649) # from google + x = Value(1, 'kg') + self.assertAlmostEqual(x.convert('kg'), 1) + self.assertAlmostEqual(x.convert('g'), 1000) + self.assertAlmostEqual(x.convert('lbs'), 2.20462) # from google + self.assertAlmostEqual(x.convert('stone'), 0.157473) # from google + x = Value(90, 'deg') + self.assertAlmostEqual(x.convert('rad'), np.pi/2) + self.assertAlmostEqual(x.convert('deg'), 90) + x = Value(30, 'min') + self.assertAlmostEqual(x.convert('sec'), 30*60) + self.assertAlmostEqual(x.convert('minutes'), 30) + self.assertAlmostEqual(x.convert('hours'), 0.5) + x = Value(2, 'days') + self.assertAlmostEqual(x.convert('min'), 2*24*60) + x = Value(1, 'year') + self.assertAlmostEqual(x.convert('min'), 525948.48) + + def test_value_does_not_convert_unitless_values(self): + x = Value(1, None) + with self.assertRaises(ValueError): + x.convert('kg') + + def test_behaves_like_tuple(self): + x = Value(25, 'cm') + self.assertAlmostEqual(x[0], 25) + self.assertAlmostEqual(x[1], 'cm') + + def test_easy_conversion_properties(self): + x = Value(25, 'cm') + self.assertAlmostEqual(x.m, 0.25) + self.assertAlmostEqual(x.cm, 25) + self.assertAlmostEqual(x.mm, 250) + with self.assertRaises(AttributeError): + _ = x.mugglemeters + + def test_comparison(self): + self.assertTrue(Value(25, 'cm') == Value(25, 'cm')) + self.assertTrue(Value(25, 'cm') == Value(250, 'mm')) + self.assertTrue(Value(25, 'cm') > Value(240, 'mm')) + self.assertTrue(Value(25, 'cm') < Value(260, 'mm')) + self.assertTrue(Value(25, 'cm') != Value(260, 'mm')) + # When comparison is to a number, we assume that the units are the same as ours + self.assertTrue(Value(25, 'cm') == 25) + + def test_multiplication(self): + x = Value(25, 'cm') + self.assertEqual(x * 2, 50) + self.assertIsInstance(x * 2, Value) + self.assertEqual(2 * x, 50) + self.assertIsInstance(2 * x, Value) + with self.assertRaises(ValueError): + # This would be cm^2; for our present purposes, multpying Value*Value is an error + _ = x * x + self.assertEqual([1, 2, 3] * x, [Value(25, 'cm'), Value(50, 'cm'), Value(75, 'cm')]) + for y in [1, 2, 3] * x: + self.assertIsInstance(y, Value) + + def test_addition_and_subtraction(self): + x = Value(25, 'cm') + self.assertEqual(x + 2, Value(27, 'cm')) + self.assertIsInstance(x + 2, Value) + self.assertEqual(2 + x, Value(27, 'cm')) + self.assertIsInstance(2 + x, Value) + self.assertEqual(x - 2, Value(23, 'cm')) + self.assertIsInstance(x - 2, Value) + # Note that although this is sort of poorly defined, we need to support this case in order to make comparisons easy + self.assertEqual(2 - x, Value(-23, 'cm')) + self.assertIsInstance(2 - x, Value) + self.assertEqual(x + Value(25, 'cm'), Value(50, 'cm')) + self.assertIsInstance(x + Value(25, 'cm'), Value) + self.assertEqual(x + Value(5, 'mm'), Value(255, 'mm')) + self.assertEqual([1, 2, 3] + x, [Value(26, 'cm'), Value(27, 'cm'), Value(28, 'cm')]) + for y in [1, 2, 3] + x: + self.assertIsInstance(y, Value) + + def test_other_numeric_methods(self): + x = Value(25, 'cm') + self.assertEqual(str(x), "25.000000 cm") + self.assertEqual(x / 2, Value(12.5, 'cm')) + self.assertIsInstance(x / 2, Value) + self.assertEqual(x / Value(1, 'cm'), Value(25, None)) + self.assertEqual(x / Value(1, 'm'), Value(0.25, None)) + self.assertEqual(x // 2, Value(12, 'cm')) + self.assertEqual(x // Value(1, 'cm'), Value(25, None)) + self.assertEqual(x // Value(1, 'm'), Value(0, None)) + self.assertIsInstance(x // 2, Value) + with self.assertRaises(AttributeError): + _ = x % 2 + with self.assertRaises(AttributeError): + _ = x ** 2 + with self.assertRaises(ValueError): + _ = 2 / x + self.assertEqual(+x, Value(25, 'cm')) + self.assertIsInstance(+x, Value) + self.assertEqual(-x, Value(-25, 'cm')) + self.assertIsInstance(-x, Value) + self.assertEqual(abs(Value(-25, 'cm')), Value(25, 'cm')) + self.assertIsInstance(abs(Value(-25, 'cm')), Value) + + def test_cast(self): + x = Value(25, 'cm') + self.assertEqual(float(x), x.value) + self.assertEqual(int(x), x.value) + self.assertEqual(int(Value(25.5, 'cm')), 25) + + def test_make_numpy_array_out_of_values(self): + x = np.array([Value(i, 'cm') for i in range(10)]) + res = np.sum(x) + self.assertIsInstance(res, Value) + +class TestValueSerialization(unittest.TestCase): + + def test_basic_serialization(self): + x = Value(25, 'cm') + x_json = json.dumps(x) + + self.assertEquals(x_json, '{"__value__": {"units": "cm", "value": 25.0}}') + x_obj = json.loads(x_json) + self.assertEquals(x, x_obj) + + def test_complex_serialization(self): + x = {str(i): Value(i, 'cm') for i in range(10)} + x_json = json.dumps(x) + x_obj = json.loads(x_json) + self.assertEquals(x, x_obj) + +class TestValueDeserialization(unittest.TestCase): + + def test_loads(self): + x_str = json.dumps({'__value__': {'value': 25.0, 'units': 'cm'}}) + x = json.loads(x_str) + self.assertEquals(x.value, 25.0) + self.assertEquals(x.units, 'cm') + + def test_from_json(self): + x = Value.from_json({'__value__': {'value': 25.0, 'units': 'cm'}}) + self.assertEquals(x.value, 25.0) + self.assertEquals(x.units, 'cm') + +if __name__ == '__main__': + unittest.main() diff --git a/blmath/util/json.py b/blmath/util/json.py new file mode 100644 index 0000000..68fe94f --- /dev/null +++ b/blmath/util/json.py @@ -0,0 +1,26 @@ +from baiji.serialization import json +from baiji.serialization.json import JSONDecoder + +class BlmathJSONDecoder(JSONDecoder): + def __init__(self): + super(BlmathJSONDecoder, self).__init__() + self.register(self.decode_value) + + def decode_value(self, dct): + from blmath.value import Value + if "__value__" in dct.keys(): + return Value.from_json(dct) + +def dump(obj, f, *args, **kwargs): + return json.dump(obj, f, *args, **kwargs) + +def load(f, *args, **kwargs): + kwargs.update(decoder=BlmathJSONDecoder()) + return json.load(f, *args, **kwargs) + +def dumps(*args, **kwargs): + return json.dumps(*args, **kwargs) + +def loads(*args, **kwargs): + kwargs.update(decoder=BlmathJSONDecoder()) + return json.loads(*args, **kwargs) diff --git a/blmath/value.py b/blmath/value.py new file mode 100644 index 0000000..37f8076 --- /dev/null +++ b/blmath/value.py @@ -0,0 +1,182 @@ +from cached_property import cached_property + +class Value(object): + ''' + Simple value class to encapsulate (value, units) and various conversions + ''' + def __init__(self, value, units=None): + ''' + units: A recognized unit, or None for a unitless value like a size or + ratio. + ''' + self.value = value + from blmath import units as unit_conversions + if units is not None and units not in unit_conversions.all_units: + raise ValueError("Unknown unit type %s" % units) + self.units = units + + @property + def units(self): + return self._units + @units.setter + def units(self, val): + self._units = val # pylint: disable=attribute-defined-outside-init + + @property + def value(self): + return self._val + @value.setter + def value(self, val): + if val is None: + raise ValueError("Value can't be None") + + self._val = float(val) # pylint: disable=attribute-defined-outside-init + + def convert(self, to_units): + from blmath import units as unit_conversions + if to_units == self.units: + return self + if self.units is None: + raise ValueError("This value is unitless: can't convert to %s" % to_units) + return Value(*unit_conversions.convert(self.value, self.units, to_units)) + + def convert_to_system_default(self, unit_system): + from blmath import units + units_class = units.units_class(self.units) + to_units = units.default_units(unit_system)[units_class] + return self.convert(to_units=to_units) + + def round(self, nearest): + from blmath.numerics import round_to + return round_to(self, nearest) + + def __float__(self): + return self.value + def __int__(self): + return int(self.value) + + def __getattr__(self, name): + from blmath import units as unit_conversions + if name not in unit_conversions.all_units: + raise AttributeError() + return self.convert(name) + + def __getitem__(self, key): + if key == 0: + return self.value + elif key == 1: + return self.units + else: + raise KeyError() + + def __mul__(self, other): + # other must be a scalar. For our present purposes, multpying Value*Value is an error + if isinstance(other, Value): + raise ValueError("%s * %s would give you units that we currently don't support" % (self.units, other.units)) + elif hasattr(other, '__iter__'): + return [self * x for x in other] + else: + return Value(self.value*other, self.units) + def __rmul__(self, other): + return self.__mul__(other) + + def __add__(self, other): + if hasattr(other, '__iter__'): + return [self + x for x in other] + return Value(self.value + float(self._comparable(other)), self.units) + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + if hasattr(other, '__iter__'): + return [self - x for x in other] + return Value(self.value - float(self._comparable(other)), self.units) + def __rsub__(self, other): + if hasattr(other, '__iter__'): + return [x - self for x in other] + return Value(float(self._comparable(other)) - self.value, self.units) + + def __div__(self, other): + if isinstance(other, Value): + try: + other_comparable = self._comparable(other) + except ValueError: + raise ValueError("%s / %s would give you units that we currently don't support" % (self.units, other.units)) + return Value(self.value / other_comparable.value, None) + return Value(self.value / other, self.units) + def __rdiv__(self, other): + raise ValueError("%s / %s... Wat." % (other, self)) + + def __floordiv__(self, other): + if isinstance(other, Value): + try: + other_comparable = self._comparable(other) + except ValueError: + raise ValueError("%s // %s would give you units that we currently don't support" % (self.units, other.units)) + return Value(self.value // other_comparable.value, None) + return Value(self.value // other, self.units) + def __rfloordiv__(self, other): + raise ValueError("%s // %s... Wat." % (other, self)) + + def __mod__(self, other): + raise AttributeError("%s %% %s... Wat." % (other, self)) + def __pow__(self, other): + raise AttributeError("%s ** %s... Wat." % (other, self)) + + def __pos__(self): + return self + def __neg__(self): + return Value(-float(self.value), self.units) + def __abs__(self): + return Value(abs(self.value), self.units) + + def __str__(self): + return "%f %s" % (self.value, self.units) + def __unicode__(self): + return str(self) + def __repr__(self): + return "" % (self.value, self.units) + + def __nonzero__(self): + return bool(self.value) + + def _comparable(self, other): + '''Make other into something that is sensible to compare to self''' + if not isinstance(other, Value): + return other + return other.convert(self.units) + + def __cmp__(self, other): + return self.value.__cmp__(float(self._comparable(other))) + def __eq__(self, other): + return self.value == float(self._comparable(other)) + def __ne__(self, other): + return self.value != float(self._comparable(other)) + def __lt__(self, other): + return self.value < float(self._comparable(other)) + def __gt__(self, other): + return self.value > float(self._comparable(other)) + def __le__(self, other): + return self.value <= float(self._comparable(other)) + def __ge__(self, other): + return self.value >= float(self._comparable(other)) + + @cached_property + def prettified(self): + from blmath import units as unit_conversions + return unit_conversions.prettify(self, self.units) + + def for_json(self): + ''' + Return internal annotated format (__value__) + + ''' + return {'__value__': {'value': self.value, 'units': self.units}} + + @classmethod + def from_json(cls, data): + ''' + Accept internal annotated format + + ''' + return cls(**data['__value__']) diff --git a/requirements.txt b/requirements.txt index 419d96d..b7912ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,10 @@ +cached-property>=1.3.0 numpy>=1.10.1 +plumbum>=1.6.3 scipy>=0.14.1 scikit-learn>=0.17.1 + baiji>=2.5.3 -baiji-serialization>=1.0.2 +baiji-serialization>=2.0.0 baiji-pod>=1.0.0 chumpy>=0.65.5