Repository: ManiacalLabs/BiblioPixel (python)

Issue:
Device brightness limiters/compressors
From [the list](https://groups.google.com/forum/#!topic/maniacal-labs-users/0Qe75tYnBc8):

> I'm also looking to limit the total power consumption of each pendant so that I don't have to have each light hanging by a thick cord. By treating each pendant as a unique object, I hope to be able to set a max total brightness, which would be used to to automatically scale down the array for each pendant as needed without compromising the brightness of a single lit pixel.


Pull request description:
New Limiter animation (fix #708)
The first commit is basically a bugfix.

Then we simplify the math handling considerably, removing a whole poorly-conceived `ColorList` class and replacing it with nothing.  Much better!

The rest is self-explanatory.

Patch:
diff --git a/bibliopixel/animation/limiter.py b/bibliopixel/animation/limiter.py
new file mode 100644
index 0000000..9463707
--- /dev/null
+++ b/bibliopixel/animation/limiter.py
@@ -0,0 +1,22 @@
+from . import wrapper
+from .. util import color_list
+from .. util.limit import Limit
+
+
+class Limiter(wrapper.Wrapper):
+    def __init__(self, *args, limit=None, **kwds):
+        """
+        :param dict limit: A construction dictionary for a Limit.
+        """
+        super().__init__(*args, **kwds)
+        self.limit = Limit(**(limit or {}))
+        self._math = color_list.Math(self.color_list)
+
+    def pre_run(self):
+        super().pre_run()
+        self.animation.layout = self.layout.clone()
+
+    def step(self, amt=1):
+        super().step(amt)
+        self._math.copy(self.color_list, self.animation.color_list)
+        self.limit.limit_colors(self.color_list, self._math)
diff --git a/bibliopixel/animation/sequence.py b/bibliopixel/animation/sequence.py
index 6ac144a..786b8a9 100644
--- a/bibliopixel/animation/sequence.py
+++ b/bibliopixel/animation/sequence.py
@@ -30,15 +30,9 @@ class Sequence(wrapper.Indexed):
         self.restart()
 
     def step(self, amt=1):
-        if not self.animations or self.completed:
+        if (not self.animations) or super().step(amt):
             return
 
-        try:
-            next(self.frames)
-            return
-        except StopIteration:
-            pass
-
         self.index += 1
         if self.index >= len(self.animations):
             if self.runner.until_complete:
diff --git a/bibliopixel/animation/wrapper.py b/bibliopixel/animation/wrapper.py
index d34209d..0d441ee 100644
--- a/bibliopixel/animation/wrapper.py
+++ b/bibliopixel/animation/wrapper.py
@@ -37,13 +37,22 @@ class Indexed(Collection):
 
         :param int old_index: the previous index, before it was changed.
         """
-        if self.current_animation:
+        if self.animation:
             log.debug('%s: %s',
                       self.__class__.__name__, self.current_animation.title)
-            self.frames = self.current_animation.generate_frames(False)
+            self.frames = self.animation.generate_frames(False)
+
+    def step(self, amt=1):
+        try:
+            next(self.frames)
+            return True
+        except StopIteration:
+            pass
+        except Exception as e:
+            log.error('Exception %s in wrapper.step()', e)
 
     @property
-    def current_animation(self):
+    def animation(self):
         """
         :returns: the selected animation based on self.index, or None if
             self.index is out of bounds
@@ -51,6 +60,14 @@ class Indexed(Collection):
         if 0 <= self._index < len(self.animations):
             return self.animations[self._index]
 
+    @property
+    def current_animation(self):
+        """
+        DEPRECATED:
+        :returns: self.animation
+        """
+        return self.animation
+
 
 class Wrapper(Indexed):
     # TODO: No unit tests cover any of this.
@@ -61,11 +78,10 @@ class Wrapper(Indexed):
         desc['animations'] = [desc.pop('animation')]
         return Collection.pre_recursion(desc)
 
+    @property
+    def animation(self):
+        return self.animations[0]
+
     def pre_run(self):
         super().pre_run()
         self.index = 0
-
-    @property
-    def animation(self):
-        # This is used elsewhere in client code.  TODO: pick one or the other!
-        return self.current_animation
diff --git a/bibliopixel/main/commands.py b/bibliopixel/main/commands.py
index 67b0894..13f0d6e 100644
--- a/bibliopixel/main/commands.py
+++ b/bibliopixel/main/commands.py
@@ -4,8 +4,8 @@ from .. util import log
 
 COMMANDS = (
     'all_pixel', 'all_pixel_test', 'clear_cache', 'color', 'demo', 'devices',
-    'info', 'load', 'list', 'monitor', 'pid', 'remove', 'reset', 'run', 'save',
-    'set', 'signal', 'show', 'update')
+    'info', 'list', 'load', 'monitor', 'pid', 'remove', 'reset', 'restart',
+    'run', 'save', 'set', 'show', 'shutdown', 'update')
 
 MODULES = {c: import_module('bibliopixel.main.' + c) for c in COMMANDS}
 
diff --git a/bibliopixel/main/signal.py b/bibliopixel/main/restart.py
similarity index 87%
rename from bibliopixel/main/signal.py
rename to bibliopixel/main/restart.py
index 01fe286..a895ef7 100644
--- a/bibliopixel/main/signal.py
+++ b/bibliopixel/main/restart.py
@@ -1,5 +1,5 @@
 """
-Send a signal to a BiblioPixel process running on this
+Send a restart signal to a BiblioPixel process running on this
 machine.
 """
 
@@ -38,6 +38,6 @@ def run(args):
 def set_parser(parser):
     parser.add_argument(
         'signal', nargs='?', default='SIGHUP', choices=CHOICES,
-        help='Signal to send.')
+        help='Signal to send.  Default SIGHUP restarts bp')
 
     parser.set_defaults(run=run)
diff --git a/bibliopixel/main/shutdown.py b/bibliopixel/main/shutdown.py
new file mode 100644
index 0000000..1e6a1b6
--- /dev/null
+++ b/bibliopixel/main/shutdown.py
@@ -0,0 +1,22 @@
+"""
+Send an interrupt signal to a BiblioPixel process running on this
+machine to kill it.
+"""
+
+DESCRIPTION = """
+Example:
+
+``````
+$ bp shutdown
+``````
+"""
+
+from . restart import run, CHOICES
+
+
+def set_parser(parser):
+    parser.add_argument(
+        'signal', nargs='?', default='SIGINT', choices=CHOICES,
+        help='Signal to send.')
+
+    parser.set_defaults(run=run)
diff --git a/bibliopixel/project/data_maker.py b/bibliopixel/project/data_maker.py
index 66e03a4..3abffaf 100644
--- a/bibliopixel/project/data_maker.py
+++ b/bibliopixel/project/data_maker.py
@@ -1,7 +1,7 @@
 from ctypes import c_float, c_uint8
 from multiprocessing.sharedctypes import RawArray
 from .. util import log
-from .. util.color_list import numpy, numpy_array
+from .. util.color_list import numpy
 from . import importer
 
 
diff --git a/bibliopixel/util/color_list.py b/bibliopixel/util/color_list.py
index e9f744e..1ec6969 100644
--- a/bibliopixel/util/color_list.py
+++ b/bibliopixel/util/color_list.py
@@ -1,12 +1,17 @@
 import copy
+from . limit import Limit
 
 try:
     import numpy
-    numpy_array = numpy.ndarray
+
+    def is_numpy(x):
+        return isinstance(x, numpy.ndarray)
 
 except:
     numpy = None
-    numpy_array = ()
+
+    def is_numpy(x):
+        return False
 
 
 class ListMath:
@@ -17,7 +22,7 @@ class ListMath:
             color_list[:] = (black for i in color_list)
 
     @staticmethod
-    def add(color_list, source, level, buffer):
+    def add(color_list, source, level=1, buffer=None):
         def add(color, src):
             return tuple(int(c + level * s) for (c, s) in zip(color, src))
 
@@ -30,6 +35,14 @@ class ListMath:
     def copy(color_list, source):
         color_list[:] = source
 
+    @staticmethod
+    def scale(color_list, gain):
+        color_list[:] = [tuple(gain * i for i in c) for c in color_list]
+
+    @staticmethod
+    def sum(color_list):
+        return sum(sum(c) for c in color_list)
+
 
 class NumpyMath:
     @staticmethod
@@ -37,7 +50,7 @@ class NumpyMath:
         color_list.fill(0)
 
     @staticmethod
-    def add(color_list, source, level, buffer):
+    def add(color_list, source, level=1, buffer=None):
         if level:
             buffer = numpy.multiply(source, level, out=buffer, casting='unsafe')
             color_list += buffer
@@ -47,45 +60,34 @@ class NumpyMath:
     def copy(color_list, source):
         numpy.copyto(color_list, source, casting='unsafe')
 
+    @staticmethod
+    def scale(color_list, gain):
+        color_list *= gain
 
-def Math(color_list):
-    return NumpyMath if isinstance(color_list, numpy_array) else ListMath
-
-
-class ColorList:
-    def __init__(self, color_list):
-        self.color_list = color_list
-        self.math = Math(color_list)
-        self.buffer = None
-
-    def clear(self):
-        self.math.clear(self.color_list)
-
-    def add(self, source, level):
-        self.buffer = self.math.add(self.color_list, source, level, self.buffer)
-
-    def copy_from(self, source):
-        self.math.copy(self.color_list, source)
+    @staticmethod
+    def sum(color_list):
+        return sum(sum(c) for c in color_list)
 
-    def __len__(self):
-        return len(self.color_list)
 
-    def __getitem__(self, i):
-        return self.color_list[i]
+def Math(color_list):
+    return NumpyMath if is_numpy(color_list) else ListMath
 
 
 class Mixer:
     def __init__(self, color_list, sources, levels=None):
-        self.color_list = ColorList(color_list)
+        self.color_list = color_list
+        self.math = Math(color_list)
         self.sources = sources
         self.levels = list(levels or [])
         needed = len(self.sources) - len(self.levels)
         if needed > 0:
             self.levels.extend(0 for i in range(needed))
+        self.buffer = None
 
     def clear(self):
-        self.color_list.clear()
+        self.math.clear(self.color_list)
 
     def mix(self, master=1):
         for source, level in zip(self.sources, self.levels):
-            self.color_list.add(source, level * master)
+            self.buffer = self.math.add(
+                self.color_list, source, level * master, self.buffer)
diff --git a/bibliopixel/util/limit.py b/bibliopixel/util/limit.py
new file mode 100644
index 0000000..c8b2909
--- /dev/null
+++ b/bibliopixel/util/limit.py
@@ -0,0 +1,39 @@
+class Limit:
+    def __init__(self, ratio=1, knee=0, gain=1, enable=True):
+        """
+        :param float ratio: the compression ratio (1 means no compression).
+            ratio should usually between 0 and 1.
+
+        :param float knee: the ratio where the compression starts to kick in.
+            knee should usually be 0 <= knee <= ratio
+
+        :param float gain: post limiter output gain. gain should usually be >= 0
+        """
+        # ratio and knee are properties, because we need to recompute _scale
+        self.ratio = ratio
+        self.knee = knee
+        self.gain = gain
+        self.enable = enable
+
+    def __bool__(self):
+        return self.enable and (self.gain != 1 or self.ratio < 1)
+
+    def limit(self, value):
+        if not (self.enable or self):
+            return value
+
+        overshoot = value - self.knee
+        if overshoot >= 0:
+            scale = (self.ratio - self.knee) / (1 - self.knee or 1)
+            value = self.knee + overshoot * scale
+
+        value = min(value, self.ratio)
+        return value * self.gain
+
+    def limit_colors(self, colors, math):
+        if self:
+            total = math.sum(colors)
+            max_total = 3 * 255 * len(colors)
+            average = total / max_total
+            limit = self.limit(average) / average
+            math.scale(colors, limit)
diff --git a/projects/30-limiter.yml b/projects/30-limiter.yml
new file mode 100644
index 0000000..b56d476
--- /dev/null
+++ b/projects/30-limiter.yml
@@ -0,0 +1,14 @@
+aliases:
+    bpa: BiblioPixelAnimations.matrix
+
+shape: [50, 50]
+
+run:
+    fps: 30
+
+animation:
+    typename: limiter
+    limit:
+      ratio: 0.6
+      knee: 0.4
+    animation: "@bpa.bloom"


Test patch:
diff --git a/test/bibliopixel/util/color_list_test.py b/test/bibliopixel/util/color_list_test.py
index f70b8c9..3435260 100644
--- a/test/bibliopixel/util/color_list_test.py
+++ b/test/bibliopixel/util/color_list_test.py
@@ -1,7 +1,7 @@
 import numpy, unittest
-from numpy import array_equal
 
-from bibliopixel.util import color_list, colors, log
+from bibliopixel.util import colors, color_list, log
+from bibliopixel.util.color_list import ListMath, NumpyMath
 
 COLORS1 = [colors.Red, colors.Green, colors.Blue, colors.White]
 COLORS2 = [colors.Black, colors.Blue, colors.Red, colors.Black]
@@ -10,24 +10,17 @@ WHITES = [colors.White, colors.White, colors.White, colors.White]
 BLACKS = [colors.Black, colors.Black, colors.Black, colors.Black]
 
 
-def array(cl):
-    return numpy.array(cl, dtype='uint8')
-
-
-def make_list(cl):
-    return color_list.ColorList(cl[:])
-
-
 def make_numpy(cl):
-    return color_list.ColorList(array(cl))
+    return numpy.array(cl, dtype='float')
 
 
-class TestBase(unittest.TestCase):
+class TestBase:
     def assert_list_equal(self, actual, expected):
-        x, y = actual.color_list, expected
-        if isinstance(x, color_list.numpy_array):
-            equals = array_equal(x, array(y))
+        x, y = actual, expected
+        if hasattr(x, 'shape'):
+            equals = numpy.array_equal(x, make_numpy(y))
         else:
+            log.printer(type(x), type(y))
             equals = (x == y)
         if not equals:
             log.printer('____')
@@ -38,68 +31,81 @@ class TestBase(unittest.TestCase):
         self.assertTrue(equals)
 
 
-class ColorListTest(TestBase):
-    def assert_list_equal(self, actual, expected):
-        x, y = actual.color_list, expected
-        if isinstance(x, color_list.numpy_array):
-            equals = array_equal(x, array(y))
-        else:
-            equals = (x == y)
-        if not equals:
-            log.printer('NOT EQUAL')
-            log.printer(x)
-            log.printer(y)
-        self.assertTrue(equals)
-
+class ColorListTest(unittest.TestCase, TestBase):
     def test_simple(self):
         self.assertIs(numpy, color_list.numpy)
-        self.assertIs(numpy.ndarray, color_list.numpy_array)
 
-        cl = make_list([])
-        self.assertIs(cl.math, color_list.ListMath)
+        self.assertFalse(color_list.is_numpy([]))
+        self.assertTrue(color_list.is_numpy(make_numpy([])))
 
-        cl = make_numpy([])
-        self.assertIs(cl.math, color_list.NumpyMath)
+        self.assertIs(color_list.Math([]), ListMath)
+        self.assertIs(color_list.Math(make_numpy([])), NumpyMath)
 
     def test_lists(self):
-        cl1 = make_list(COLORS1)
-        cl2 = make_list(COLORS2)
-        cl1.add(cl2.color_list, 0)
+        cl1 = COLORS1[:]
+        cl2 = COLORS2[:]
+        ListMath.add(cl1, cl2, 0)
         self.assert_list_equal(cl1, COLORS1)
-        cl1.add(cl2.color_list, 1)
+        ListMath.add(cl1, cl2)
         self.assert_list_equal(cl1, SUM12)
 
     def test_numpy(self):
         cl1 = make_numpy(COLORS1)
         cl2 = make_numpy(COLORS2)
-        cl1.add(cl2.color_list, 0)
+        NumpyMath.add(cl1, cl2, 0)
         self.assert_list_equal(cl1, COLORS1)
-        cl1.add(cl2.color_list, 1)
-        self.assertTrue(cl1, SUM12)
+        NumpyMath.add(cl1, cl2, 1)
+        self.assert_list_equal(cl1, SUM12)
 
     def test_clear_list(self):
-        cl = make_list(COLORS1)
-        cl.clear()
+        cl = COLORS1[:]
+        ListMath.clear(cl)
         self.assert_list_equal(cl, BLACKS)
 
     def test_clear_numpy(self):
         cl = make_numpy(COLORS1)
-        cl.clear()
+        NumpyMath.clear(cl)
+        self.assert_list_equal(cl, BLACKS)
         self.assert_list_equal(cl, BLACKS)
 
     def test_copy_list(self):
-        cl = make_list(COLORS1)
-        cl = make_numpy(COLORS1)
-        cl.copy_from(array(COLORS2))
+        cl = COLORS1[:]
+        ListMath.copy(cl, COLORS2)
         self.assert_list_equal(cl, COLORS2)
 
     def test_copy_numpy(self):
         cl = make_numpy(COLORS1)
-        cl.copy_from(array(COLORS2))
+        NumpyMath.copy(cl, make_numpy(COLORS2))
         self.assert_list_equal(cl, COLORS2)
 
+    def test_sum_list(self):
+        self.assertEquals(ListMath.sum(COLORS1), 6 * 255)
+
+    def test_sum_numpy(self):
+        self.assertEquals(NumpyMath.sum(make_numpy(COLORS1)), 6 * 255)
+
+    def test_scale_list(self):
+        cl = COLORS1[:]
+        ListMath.scale(cl, 0.5)
+        self.assert_list_equal(
+            cl,
+            [(127.5, 0.0, 0.0),
+             (0.0, 127.5, 0.0),
+             (0.0, 0.0, 127.5),
+             (127.5, 127.5, 127.5)])
 
-class MixerTest(TestBase):
+    def test_scale_numpy(self):
+        cl = make_numpy(COLORS1)
+        NumpyMath.scale(cl, 0.5)
+        self.assert_list_equal(
+            cl,
+            [(127.5, 0.0, 0.0),
+             (0.0, 127.5, 0.0),
+             (0.0, 0.0, 127.5),
+             (127.5, 127.5, 127.5)])
+
+
+class MixerTest(unittest.TestCase, TestBase):
     def do_test(self, mixer, thirds):
         self.assertEqual(mixer.levels, [0, 0, 0])
         self.assert_list_equal(mixer.color_list, COLORS1)
@@ -144,9 +150,7 @@ class MixerTest(TestBase):
 
     def test_numpy(self):
         mixer = color_list.Mixer(
-            array(COLORS1), [array(COLORS2), array(WHITES), array(BLACKS)])
+            make_numpy(COLORS1),
+            [make_numpy(COLORS2), make_numpy(WHITES), make_numpy(BLACKS)])
         self.do_test(mixer,
                      [(85, 85, 85), (85, 85, 170), (170, 85, 85), (85, 85, 85)])
-
-
-del TestBase
diff --git a/test/bibliopixel/util/limit_test.py b/test/bibliopixel/util/limit_test.py
new file mode 100644
index 0000000..adf1739
--- /dev/null
+++ b/test/bibliopixel/util/limit_test.py
@@ -0,0 +1,80 @@
+import numpy, unittest
+from fractions import Fraction
+from bibliopixel.util.limit import Limit
+from bibliopixel.util.color_list import ListMath, NumpyMath
+from . import color_list_test
+
+
+class LimitTest(unittest.TestCase):
+    def test_default(self):
+        limit = Limit()
+        self.assertFalse(limit)
+
+        # Note - in practice you wouldn't bother with using Fraction,
+        # but it's used here to get exact equality in unit tests.
+        actual = [limit.limit(Fraction(i, 8)) for i in range(9)]
+        expected = [Fraction(i, 8) for i in range(9)]
+        self.assertEqual(actual, expected)
+
+    def test_ratio(self):
+        limit = Limit(ratio=Fraction(3, 4))
+        self.assertTrue(limit)
+
+        actual = [limit.limit(Fraction(i, 8)) for i in range(11)]
+        expected = [Fraction(i, 8) * 3 / 4 for i in range(9)] + [
+            Fraction(3, 4), Fraction(3, 4)]
+        self.assertEqual(actual, expected)
+
+    def test_knee(self):
+        limit = Limit(ratio=Fraction(3, 4), knee=Fraction(1, 4))
+        self.assertTrue(limit)
+
+        actual = [limit.limit(Fraction(i, 8)) for i in range(11)]
+        expected = [Fraction(0), Fraction(1, 8), Fraction(1, 4),
+                    Fraction(1, 3), Fraction(5, 12), Fraction(1, 2),
+                    Fraction(7, 12), Fraction(2, 3), Fraction(3, 4),
+                    Fraction(3, 4), Fraction(3, 4)]
+        self.assertEqual(actual, expected)
+
+    def test_knee_gain(self):
+        limit = Limit(
+            ratio=Fraction(3, 4), knee=Fraction(1, 4), gain=Fraction(1, 2))
+        self.assertTrue(limit)
+
+        actual = [limit.limit(Fraction(i, 8)) for i in range(11)]
+        expected = [Fraction(0) / 2, Fraction(1, 8) / 2, Fraction(1, 4) / 2,
+                    Fraction(1, 3) / 2, Fraction(5, 12) / 2, Fraction(1, 2) / 2,
+                    Fraction(7, 12) / 2, Fraction(2, 3) / 2, Fraction(3, 4) / 2,
+                    Fraction(3, 4) / 2, Fraction(3, 4) / 2]
+        self.assertEqual(actual, expected)
+
+    def test_knee_gain_disabled(self):
+        limit = Limit(
+            ratio=Fraction(3, 4), knee=Fraction(1, 4), gain=Fraction(1, 2))
+        self.assertTrue(limit)
+
+        limit.enable = False
+        self.assertFalse(limit)
+
+        actual = [limit.limit(Fraction(i, 8)) for i in range(11)]
+        expected = [Fraction(i, 8) for i in range(11)]
+        self.assertEqual(actual, expected)
+
+    def test_colors(self):
+        limit = Limit(
+            ratio=Fraction(3, 4), knee=Fraction(1, 4), gain=Fraction(1, 2))
+        self.assertTrue(limit)
+
+        cl = color_list_test.COLORS1[:]
+        limit.limit_colors(cl, ListMath)
+        c = 106 + 1 / 4
+        expected = [(c, 0, 0), (0, c, 0), (0, 0, c), (c, c, c)]
+
+        self.assertEqual(len(expected), len(cl))
+        asserts = 0
+        for exp, act in zip(expected, cl):
+            for e, a in zip(exp, act):
+                self.assertAlmostEqual(e, a)
+                asserts += 1
+
+        self.assertEqual(asserts, 12)