Files
led2/lightsync/animations/fire.py
Claude f4da9564d4 feat(03-02): implement animation library — 7 types with registry and factory
- 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
2026-04-06 21:47:39 +00:00

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),
)