Repository: ManiacalLabs/BiblioPixel
Pull request #594 (2018-01-26 18:13:25)

Ops composes arithmetic operations
This is very handy - I hope.  It lets you do simple calculations in data without the full effort of some sort of expression evaluation language, so you just add `['mul', 0.5]` or `['sqrt']` to a control to do scaling, offsetting and that sort of thing.


Patch:
diff --git a/bibliopixel/controllers/ops.py b/bibliopixel/controllers/ops.py
new file mode 100644
index 0000000..312f5c3
--- /dev/null
+++ b/bibliopixel/controllers/ops.py
@@ -0,0 +1,138 @@
+import math, operator
+from . address import Address
+
+"""
+Simple numerical operations put together in a list, useful for
+scaling or offsetting some incoming numerical value.
+
+Examples:
+
+   Ops()(x)
+returns the number unchanged
+
+   Ops('mul', 0.5)(x)
+multiples x by 0.5
+
+   Ops('add', 0.1)(x)
+adds 1 to x
+
+   Ops('sqrt')(x)
+returns the square root of x
+
+   Ops('mul', 0.5, 'add', 1, 'sqrt')(x)
+returns sqrt(1 + 0.5 * x)
+
+"""
+
+
+class Op:
+    def __init__(self, op, *values):
+        if len(values) > len(OPS):
+            raise KeyError('Too many arguments for ' + op)
+        ops = OPS[len(values)]
+
+        if op not in ops:
+            raise KeyError(OPS_ERROR.format(op, ', '.join(sorted(ops))))
+        self.op = ops[op]
+
+        self.opname = op
+        self.values = values
+
+    def __call__(self, x):
+        return self.op(x, *self.values)
+
+
+class Ops:
+    def __init__(self, *ops):
+        self.ops = tuple(_make_ops(ops))
+
+    def __call__(self, x):
+        for op in self.ops:
+            x = op(x)
+        return x
+
+
+def _make_ops(ops):
+    op, values = None, []
+    for o in ops:
+        if isinstance(o, str):
+            # We're starting a new operation
+            if op:
+                yield Op(op, *values)
+            op, values = o, []
+        else:
+            # We're in the middle of an operation
+            if not op:
+                raise ValueError('Value without op')
+            values.append(o)
+    if op:
+        yield Op(op, *values)
+
+
+def _two_ops():
+    def add(ops, op):
+        f = getattr(operator, 'truediv' if op == 'div' else op)
+        ops[op] = f
+        if op not in COMMUTATIVE:
+            ops['r' + op] = lambda x, y: f(y, x)
+
+    ops = {}
+    for op in TWO_OPERATORS:
+        add(ops, op)
+
+    ops.update(max=max, min=min, gamma=ops['rpow'])
+    return ops
+
+
+def _join(items, indent='    ', per_line=8):
+    lines = []
+    for i in range(0, len(items), 8):
+        lines.append(indent + ', '.join(items[i:i + 8]) + '\n')
+    return ''.join(lines)
+
+
+TWO_OPERATORS = 'add', 'div', 'mod', 'mul', 'pow', 'sub'
+COMMUTATIVE = 'add', 'mul'
+
+ONE_OPERATORS = (
+    'acos',
+    'acosh',
+    'asin',
+    'asinh',
+    'atan',
+    'atanh',
+    'ceil',
+    'cos',
+    'cosh',
+    'degrees',
+    'erf',
+    'erfc',
+    'exp',
+    'expm1',
+    'fabs',
+    'floor',
+    'gamma',
+    'lgamma',
+    'log',
+    'log10',
+    'log1p',
+    'log2',
+    'radians',
+    'sin',
+    'sinh',
+    'sqrt',
+    'tan',
+    'tanh',
+    'trunc')
+
+OPS = [{k: getattr(math, k) for k in ONE_OPERATORS}, _two_ops()]
+
+OPS_ERROR = """\
+Do not understand operator {0}.
+
+One argument operators are:
+   %s
+
+Two argument operators are:
+   %s
+""" % tuple(_join(sorted(o)) for o in OPS)


Test patch:
diff --git a/test/bibliopixel/controllers/ops_test.py b/test/bibliopixel/controllers/ops_test.py
new file mode 100644
index 0000000..274a5fb
--- /dev/null
+++ b/test/bibliopixel/controllers/ops_test.py
@@ -0,0 +1,59 @@
+import unittest
+from bibliopixel.controllers.ops import Ops, OPS
+
+
+class OpsTest(unittest.TestCase):
+
+    def test_empty(self):
+        ops = Ops()
+        self.assertEqual(ops(0), 0)
+        self.assertEqual(ops(1), 1)
+
+    def test_single(self):
+        ops = Ops('sqrt')
+        self.assertEqual(ops(0), 0)
+        self.assertEqual(ops(1), 1)
+        self.assertAlmostEqual(ops(2) * ops(2), 2)
+
+    def test_two(self):
+        ops = Ops('mul', 2)
+        self.assertEqual(ops(0), 0)
+        self.assertEqual(ops(1), 2)
+
+        ops = Ops('div', 2)
+        self.assertEqual(ops(0), 0)
+        self.assertEqual(ops(2), 1)
+
+        ops = Ops('rdiv', 10)
+        self.assertEqual(ops(2), 5)
+        self.assertEqual(ops(10), 1)
+
+    def test_multi(self):
+        ops = Ops('mul', 2, 'sub', 4, 'sqrt')
+        self.assertEqual(ops(2), 0)
+        self.assertEqual(ops(10), 4)
+
+    def test_all(self):
+        for k, v in OPS[0].items():
+            v(0.5 if k == 'atanh' else 1)
+
+        for v in OPS[1].values():
+            v(1, 1)
+
+    def test_error(self):
+        def raisesOps(*s):
+            with self.assertRaises(Exception) as e:
+                Ops(*s)
+            return e
+
+        Ops()
+        raisesOps(0)
+        raisesOps('unknown')
+        raisesOps('mul')
+
+        Ops('sqrt')
+        raisesOps('sqrt', 2)
+        raisesOps('sqrt', 'mul')
+
+        Ops('sqrt', 'mul', 2)
+        raisesOps('sqrt', 'mul', 2, 3)