- 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
81 lines
2.5 KiB
Python
81 lines
2.5 KiB
Python
"""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),
|
|
)
|