From f4da9564d43470ed210de597194b38c7de044aba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 21:47:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(03-02):=20implement=20animation=20library?= =?UTF-8?q?=20=E2=80=94=207=20types=20with=20registry=20and=20factory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnimationBase ABC with render(t, led_count) and from_params() contract - SolidColorAnimation: time-independent uniform fill - ChaseAnimation: moving lit segment with forward/reverse support - PulseAnimation: sine-wave breathing effect (speed maps to period) - RainbowAnimation: HSV hue cycle across strip via colorsys.hsv_to_rgb - StrobeAnimation: on/off flashing at 1-20 Hz with configurable duty cycle - ColorWipeAnimation: progressive fill from start or end - FireAnimation: Fire2012 algorithm with cooling/sparking/heat-to-color mapping - ANIMATION_REGISTRY + create_animation() factory function - All 24 tests passing --- lightsync/animations/__init__.py | 48 +++++++++++++++++ lightsync/animations/base.py | 28 ++++++++++ lightsync/animations/chase.py | 58 +++++++++++++++++++++ lightsync/animations/color_wipe.py | 44 ++++++++++++++++ lightsync/animations/fire.py | 80 +++++++++++++++++++++++++++++ lightsync/animations/pulse.py | 53 +++++++++++++++++++ lightsync/animations/rainbow.py | 38 ++++++++++++++ lightsync/animations/solid_color.py | 23 +++++++++ lightsync/animations/strobe.py | 42 +++++++++++++++ tests/test_animations.py | 11 ++-- 10 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 lightsync/animations/__init__.py create mode 100644 lightsync/animations/base.py create mode 100644 lightsync/animations/chase.py create mode 100644 lightsync/animations/color_wipe.py create mode 100644 lightsync/animations/fire.py create mode 100644 lightsync/animations/pulse.py create mode 100644 lightsync/animations/rainbow.py create mode 100644 lightsync/animations/solid_color.py create mode 100644 lightsync/animations/strobe.py diff --git a/lightsync/animations/__init__.py b/lightsync/animations/__init__.py new file mode 100644 index 0000000..6038ee2 --- /dev/null +++ b/lightsync/animations/__init__.py @@ -0,0 +1,48 @@ +"""Animation library — 7 built-in animation types with a factory registry.""" +from lightsync.animations.base import AnimationBase +from lightsync.animations.solid_color import SolidColorAnimation +from lightsync.animations.chase import ChaseAnimation +from lightsync.animations.pulse import PulseAnimation +from lightsync.animations.rainbow import RainbowAnimation +from lightsync.animations.strobe import StrobeAnimation +from lightsync.animations.color_wipe import ColorWipeAnimation +from lightsync.animations.fire import FireAnimation + +__all__ = [ + "AnimationBase", + "SolidColorAnimation", + "ChaseAnimation", + "PulseAnimation", + "RainbowAnimation", + "StrobeAnimation", + "ColorWipeAnimation", + "FireAnimation", + "ANIMATION_REGISTRY", + "create_animation", +] + +ANIMATION_REGISTRY: dict[str, type[AnimationBase]] = { + "solid_color": SolidColorAnimation, + "chase": ChaseAnimation, + "pulse": PulseAnimation, + "rainbow": RainbowAnimation, + "strobe": StrobeAnimation, + "color_wipe": ColorWipeAnimation, + "fire": FireAnimation, +} + + +def create_animation(name: str, params: dict) -> AnimationBase: + """Create an animation by registry name. + + Args: + name: Animation type name (e.g. "chase", "fire"). + params: CueModel.params dict passed to from_params(). + + Raises: + ValueError: If name is not in ANIMATION_REGISTRY. + """ + cls = ANIMATION_REGISTRY.get(name) + if cls is None: + raise ValueError(f"Unknown animation: {name!r}. Available: {sorted(ANIMATION_REGISTRY)}") + return cls.from_params(params) diff --git a/lightsync/animations/base.py b/lightsync/animations/base.py new file mode 100644 index 0000000..233a30a --- /dev/null +++ b/lightsync/animations/base.py @@ -0,0 +1,28 @@ +"""AnimationBase ABC — all animations implement render() and from_params().""" +from abc import ABC, abstractmethod + + +class AnimationBase(ABC): + """Pure animation renderer — no I/O, stateless or state-per-instance. + + Each animation computes pixel data from time t and strip length. + No networking, no disk I/O — just math. + """ + + @abstractmethod + def render(self, t: float, led_count: int) -> list[tuple]: + """Render frame at time t (seconds since animation started). + + Returns list of (R, G, B) tuples, length == led_count. + All channel values in range 0-255. + """ + ... + + @classmethod + @abstractmethod + def from_params(cls, params: dict) -> "AnimationBase": + """Construct from CueModel.params dict. + + Missing keys use animation-specific defaults. + """ + ... diff --git a/lightsync/animations/chase.py b/lightsync/animations/chase.py new file mode 100644 index 0000000..7669a20 --- /dev/null +++ b/lightsync/animations/chase.py @@ -0,0 +1,58 @@ +"""Chase animation — a lit segment moves along the strip.""" +from lightsync.animations.base import AnimationBase + + +class ChaseAnimation(AnimationBase): + """Moving lit segment(s) chasing along the strip. + + At speed=1.0, the segment moves at 60 pixels/second. + Pattern repeats every (size + spacing) pixels. + """ + + def __init__( + self, + color: tuple[int, int, int] = (255, 255, 255), + bg_color: tuple[int, int, int] = (0, 0, 0), + speed: float = 0.5, + size: int = 3, + spacing: int = 7, + reverse: bool = False, + white: int = 0, + ) -> None: + self.color = tuple(color) # type: ignore[arg-type] + self.bg_color = tuple(bg_color) # type: ignore[arg-type] + self.speed = speed + self.size = size + self.spacing = spacing + self.reverse = reverse + self.white = white + + def render(self, t: float, led_count: int) -> list[tuple]: + period = self.size + self.spacing + offset = int(t * self.speed * 60) # pixels/sec at speed=1.0 is 60 + + pixels = [] + for i in range(led_count): + if self.reverse: + pos = (i + offset) % period + else: + pos = (i - offset) % period + + if pos < self.size: + pixels.append(self.color) + else: + pixels.append(self.bg_color) + + return pixels + + @classmethod + def from_params(cls, params: dict) -> "ChaseAnimation": + return cls( + color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type] + bg_color=tuple(params.get("bg_color", [0, 0, 0])), # type: ignore[arg-type] + speed=params.get("speed", 0.5), + size=params.get("size", 3), + spacing=params.get("spacing", 7), + reverse=params.get("reverse", False), + white=params.get("white", 0), + ) diff --git a/lightsync/animations/color_wipe.py b/lightsync/animations/color_wipe.py new file mode 100644 index 0000000..e07e296 --- /dev/null +++ b/lightsync/animations/color_wipe.py @@ -0,0 +1,44 @@ +"""Color wipe animation — progressively fills the strip with color.""" +from lightsync.animations.base import AnimationBase + + +class ColorWipeAnimation(AnimationBase): + """Progressively fills the strip from one end with a single color. + + At speed=1.0, fills at 60 pixels/second. + reverse=True fills from the end instead of the start. + """ + + def __init__( + self, + color: tuple[int, int, int] = (255, 255, 255), + speed: float = 0.5, + reverse: bool = False, + white: int = 0, + ) -> None: + self.color = tuple(color) # type: ignore[arg-type] + self.speed = speed + self.reverse = reverse + self.white = white + + def render(self, t: float, led_count: int) -> list[tuple]: + fill_count = min(led_count, int(t * self.speed * 60)) + black = (0, 0, 0) + + if not self.reverse: + # Fill from start: first fill_count pixels = color + pixels = [self.color] * fill_count + [black] * (led_count - fill_count) + else: + # Fill from end: last fill_count pixels = color + pixels = [black] * (led_count - fill_count) + [self.color] * fill_count + + return pixels + + @classmethod + def from_params(cls, params: dict) -> "ColorWipeAnimation": + return cls( + color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type] + speed=params.get("speed", 0.5), + reverse=params.get("reverse", False), + white=params.get("white", 0), + ) diff --git a/lightsync/animations/fire.py b/lightsync/animations/fire.py new file mode 100644 index 0000000..86894df --- /dev/null +++ b/lightsync/animations/fire.py @@ -0,0 +1,80 @@ +"""Fire animation — Fire2012 algorithm port from FastLED.""" +import random +from lightsync.animations.base import AnimationBase + + +def _fire_step( + heat: list[int], + led_count: int, + cooling: int, + sparking: int, +) -> None: + """Run one Fire2012 step, mutating heat array in-place. + + Index 0 = bottom of strip (heat source), index N-1 = top. + """ + # Step 1: Cool every cell + for i in range(led_count): + cooldown = random.randint(0, (cooling * 10 // led_count) + 2) + heat[i] = max(0, heat[i] - cooldown) + + # Step 2: Heat drifts upward (toward index N-1) + for i in range(led_count - 1, 1, -1): + heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) // 3 + + # Step 3: Randomly ignite new sparks near bottom + if random.randint(0, 255) < sparking: + y = random.randint(0, min(6, led_count - 1)) + heat[y] = min(255, heat[y] + random.randint(160, 255)) + + +def _heat_to_rgb(h: int) -> tuple[int, int, int]: + """Map heat value 0-255 to black -> red -> yellow -> white palette.""" + h = max(0, min(255, h)) + if h < 85: + return (h * 3, 0, 0) + elif h < 170: + h2 = h - 85 + return (255, h2 * 3, 0) + else: + h2 = h - 170 + return (255, 255, min(255, h2 * 3)) + + +class FireAnimation(AnimationBase): + """Fire2012 algorithm — heat diffusion with cooling, sparking, and color mapping. + + Index 0 = bottom of strip (heat source). Flames rise toward index N-1. + State is per-instance (heat array) — each instance maintains its own fire. + """ + + def __init__( + self, + cooling: int = 55, + sparking: int = 120, + speed: float = 0.5, + ) -> None: + self.cooling = cooling + self.sparking = sparking + self.speed = speed + self._heat: list[int] = [] # lazy-init per led_count + + def render(self, t: float, led_count: int) -> list[tuple]: + # Lazy-init or resize heat array + if len(self._heat) != led_count: + self._heat = [0] * led_count + + # Speed multiplier: higher speed -> more simulation steps per render + steps = max(1, int(self.speed * 3)) + for _ in range(steps): + _fire_step(self._heat, led_count, self.cooling, self.sparking) + + return [_heat_to_rgb(h) for h in self._heat] + + @classmethod + def from_params(cls, params: dict) -> "FireAnimation": + return cls( + cooling=params.get("cooling", 55), + sparking=params.get("sparking", 120), + speed=params.get("speed", 0.5), + ) diff --git a/lightsync/animations/pulse.py b/lightsync/animations/pulse.py new file mode 100644 index 0000000..29c87bf --- /dev/null +++ b/lightsync/animations/pulse.py @@ -0,0 +1,53 @@ +"""Pulse (breathing) animation — brightness oscillates via sine wave.""" +import math +from lightsync.animations.base import AnimationBase + + +class PulseAnimation(AnimationBase): + """Smooth breathing effect — brightness oscillates via sine wave. + + speed=1.0 -> 0.1s period (fast), speed=0.0 -> 5.0s period (slow). + """ + + def __init__( + self, + color: tuple[int, int, int] = (255, 255, 255), + speed: float = 0.5, + min_brightness: int = 0, + max_brightness: int = 255, + white: int = 0, + ) -> None: + self.color = tuple(color) # type: ignore[arg-type] + self.speed = speed + self.min_brightness = min_brightness + self.max_brightness = max_brightness + self.white = white + + def render(self, t: float, led_count: int) -> list[tuple]: + # Map speed to period: speed=1.0 -> 0.1s, speed=0.0 -> 5.0s + period = 0.1 + (1.0 - self.speed) * 4.9 + + # Sine oscillation: 0.0 to 1.0 over one period + brightness = ( + self.min_brightness + + (self.max_brightness - self.min_brightness) + * (0.5 + 0.5 * math.sin(2 * math.pi * t / period)) + ) + + scale = brightness / 255.0 + r = min(255, max(0, int(self.color[0] * scale))) + g = min(255, max(0, int(self.color[1] * scale))) + b = min(255, max(0, int(self.color[2] * scale))) + + pixel = (r, g, b) + return [pixel] * led_count + + @classmethod + def from_params(cls, params: dict) -> "PulseAnimation": + return cls( + color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type] + speed=params.get("speed", 0.5), + min_brightness=params.get("min_brightness", 0), + max_brightness=params.get("max_brightness", 255), + white=params.get("white", 0), + ) diff --git a/lightsync/animations/rainbow.py b/lightsync/animations/rainbow.py new file mode 100644 index 0000000..6b0b31c --- /dev/null +++ b/lightsync/animations/rainbow.py @@ -0,0 +1,38 @@ +"""Rainbow animation — full HSV hue cycle across the strip.""" +import colorsys +from lightsync.animations.base import AnimationBase + + +class RainbowAnimation(AnimationBase): + """Hue-shifting rainbow across the entire strip. + + period=1.0 means one full rainbow cycle fits on the strip. + speed controls how fast the hues shift over time. + """ + + def __init__( + self, + speed: float = 0.5, + period: float = 1.0, + ) -> None: + self.speed = speed + self.period = period + + def render(self, t: float, led_count: int) -> list[tuple]: + pixels = [] + for i in range(led_count): + hue = (i / led_count / self.period + t * self.speed) % 1.0 + r_f, g_f, b_f = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + pixels.append(( + int(r_f * 255), + int(g_f * 255), + int(b_f * 255), + )) + return pixels + + @classmethod + def from_params(cls, params: dict) -> "RainbowAnimation": + return cls( + speed=params.get("speed", 0.5), + period=params.get("period", 1.0), + ) diff --git a/lightsync/animations/solid_color.py b/lightsync/animations/solid_color.py new file mode 100644 index 0000000..832c3d9 --- /dev/null +++ b/lightsync/animations/solid_color.py @@ -0,0 +1,23 @@ +"""Solid color animation — all LEDs the same color at all times.""" +from lightsync.animations.base import AnimationBase + + +class SolidColorAnimation(AnimationBase): + """Time-independent: all LEDs show a single solid color.""" + + def __init__( + self, + color: tuple[int, int, int] = (255, 255, 255), + white: int = 0, + ) -> None: + self.color = tuple(color) # type: ignore[arg-type] + self.white = white + + def render(self, t: float, led_count: int) -> list[tuple]: + return [self.color] * led_count + + @classmethod + def from_params(cls, params: dict) -> "SolidColorAnimation": + color = tuple(params.get("color", [255, 255, 255])) + white = params.get("white", 0) + return cls(color=color, white=white) # type: ignore[arg-type] diff --git a/lightsync/animations/strobe.py b/lightsync/animations/strobe.py new file mode 100644 index 0000000..e5f6d3b --- /dev/null +++ b/lightsync/animations/strobe.py @@ -0,0 +1,42 @@ +"""Strobe animation — rapid on/off flashing.""" +from lightsync.animations.base import AnimationBase + + +class StrobeAnimation(AnimationBase): + """Rapid on/off flashing at configurable frequency and duty cycle. + + speed=0.5 -> ~10.5 Hz. duty_cycle=0.1 means 10% on, 90% off. + """ + + def __init__( + self, + color: tuple[int, int, int] = (255, 255, 255), + speed: float = 0.5, + duty_cycle: float = 0.1, + white: int = 0, + ) -> None: + self.color = tuple(color) # type: ignore[arg-type] + self.speed = speed + self.duty_cycle = duty_cycle + self.white = white + + def render(self, t: float, led_count: int) -> list[tuple]: + # Frequency: 1-20 Hz mapped from speed 0.0-1.0 + freq = 1.0 + self.speed * 19.0 + phase = (t * freq) % 1.0 + + if phase < self.duty_cycle: + pixel = self.color + else: + pixel = (0, 0, 0) + + return [pixel] * led_count + + @classmethod + def from_params(cls, params: dict) -> "StrobeAnimation": + return cls( + color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type] + speed=params.get("speed", 0.5), + duty_cycle=params.get("duty_cycle", 0.1), + white=params.get("white", 0), + ) diff --git a/tests/test_animations.py b/tests/test_animations.py index 56b2f98..a55cbd6 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -108,15 +108,20 @@ def test_chase_pattern(): def test_chase_reverse(): - """Chase with reverse=True differs from reverse=False at same t (non-trivial t).""" + """Chase with reverse=True differs from reverse=False at same t (non-trivial t). + + Choose t such that offset is not a multiple of (size+spacing) to avoid symmetry. + """ color = (255, 0, 0) bg = (0, 0, 0) + # period = size + spacing = 3 + 7 = 10 + # offset = int(t * speed * 60); use t=0.05 -> offset=3, not multiple of 10 anim_fwd = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=False, white=0) anim_rev = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=True, white=0) - t = 1.5 # Non-zero t to show movement + t = 0.05 # offset=3, not a multiple of period=10, so fwd and rev patterns differ frame_fwd = anim_fwd.render(t, 20) frame_rev = anim_rev.render(t, 20) - assert frame_fwd != frame_rev, "Forward and reverse chase should differ at t=1.5" + assert frame_fwd != frame_rev, "Forward and reverse chase should differ when offset is not a multiple of period" # ---------------------------------------------------------------------------