Skip to content

Lab 16: Comet

Pixel says...

Pixel waves hello Look up! A comet is zooming by. We'll build one with a bright head and a tail that fades away. Let's light this up!

Program file: 16-comet.py

What you'll learn

  • How a list of brightness levels makes a tail that fades
  • How to scale a color by a number between 0 and 1
  • How % lets the comet wrap around the strip
  • How nested loops step through colors and positions

What you'll need

  • Your base kit: a Pico, a breadboard, and the 30-pixel LED strip, wired as shown in the Kit User's Guide
  • The config.py file saved on the Pico (see Getting Code onto the Kit)
  • Thonny open and connected to your Pico

The program

This program sends a comet around the strip once for each of nine colors, then starts over.

16-comet.py
# Lab 16: Comet
# Filename: 16-comet.py
# Version: 1.0.0
#
# A bright head with a fading tail travels along the strip.

from machine import Pin
from neopixel import NeoPixel
from utime import sleep
import config

# hardware settings from config.py
NEOPIXEL_PIN = config.NEOPIXEL_PIN
NUMBER_PIXELS = config.NUMBER_PIXELS

strip = NeoPixel(Pin(NEOPIXEL_PIN), NUMBER_PIXELS)

red = (255, 0, 0)
orange = (140, 60, 0)
yellow = (255, 255, 0)
green = (0, 255, 0)
blue = (0, 0, 255)
cyan = (0, 255, 255)
indigo = (75, 0, 130)
violet = (138, 43, 226)
white = (128, 128, 128)
colors = (red, orange, yellow, green, blue, indigo, violet, cyan, white)
color_count = len(colors)
levels = [255, 128, 64, 32, 16, 8, 4, 2, 1]
level_count = len(levels)

def wheel(pos):
    # Input a value 0 to 255 to get a color value.
    # The colors are a transition r - g - b - back to r.
    if pos < 0 or pos > 255:
        return (0, 0, 0)
    if pos < 85:
        return (255 - pos * 3, pos * 3, 0)
    if pos < 170:
        pos -= 85
        return (0, 255 - pos * 3, pos * 3)
    pos -= 170
    return (pos * 3, 0, 255 - pos * 3)

# offset should be incremented by one for motion
def draw_comet_tail(offset, color, delay):
    offset = offset % NUMBER_PIXELS
    for i in range(0, color_count):
        target = ((level_count - i - 1) + offset) % NUMBER_PIXELS
        # number to scale by
        scale = (levels[i] / 255)
        strip[target] = (int(color[0]*scale), int(color[1]*scale), int(color[2]*scale))      
        if offset > 0:
            strip[offset-1] = (0,0,0)
        if offset == NUMBER_PIXELS-1:
            strip[offset] = (0,0,0)
        strip.write()
        sleep(delay)


def clear():
    for i in range(0, NUMBER_PIXELS):
        strip[i] = (0,0,0)
    strip.write()

# setup
counter = 0
clear()

# main loop
while True:
    for color_index in range(0, color_count):
        for i in range(0, NUMBER_PIXELS):
            draw_comet_tail(counter, colors[color_index], .005)
            counter += 1

Run it. A bright head with a short fading tail travels along the strip. The comet changes color after every lap.

How it works

Nine brightness levels

levels = [255, 128, 64, 32, 16, 8, 4, 2, 1]
level_count = len(levels)

A list is an ordered group of values inside square brackets. This list holds nine brightness levels. Each number is about half of the one before it. The value 255 is full brightness, and 1 is almost off.

The len() function counts the items in a list, so level_count is 9. Every pixel in the comet gets one level. Your eyes see brightness on a curve, not a straight line, so halving looks like a smooth fade. The Perceived vs. Linear Brightness MicroSim shows why.

Scale a color

scale = (levels[i] / 255)
strip[target] = (int(color[0]*scale), int(color[1]*scale), int(color[2]*scale))

The first line turns a level into a number between 0 and 1. Level 255 gives 1.0, level 128 gives about 0.5, and level 1 gives about 0.004.

The second line multiplies each part of the color by scale. In a color, color[0] is the red amount, color[1] is green, and color[2] is blue. The int() function drops the decimal part, because color numbers must be whole numbers. Yellow (255, 255, 0) at level 64 becomes about (64, 64, 0).

Find the head and the tail

def draw_comet_tail(offset, color, delay):
    offset = offset % NUMBER_PIXELS
    for i in range(0, color_count):
        target = ((level_count - i - 1) + offset) % NUMBER_PIXELS

The offset tells where the comet is. The loop runs nine times, once for each pixel in the comet. Since level_count is 9, the target is offset + 8 - i. The head (level 255) is at offset + 8. Each pixel behind it is one step lower and one level dimmer. The % wraps any number past the end of the strip back to the start.

Here is the comet when offset is 0:

Loop count i Pixel target Level
0 8 (the head) 255
1 7 128
2 6 64
3 5 32
4 4 16
5 3 8
6 2 4
7 1 2
8 0 (the tail end) 1

Each pass through the loop ends with strip.write() and sleep(delay). The comet appears one pixel at a time, head first. Nine pauses of 0.005 seconds add up to about 0.045 seconds per comet position.

Erase the old tail

if offset > 0:
    strip[offset-1] = (0,0,0)
if offset == NUMBER_PIXELS-1:
    strip[offset] = (0,0,0)

The comet moves one pixel at a time. The pixel right behind the new tail end belongs to the old comet, so the first if turns it off. At offset 29, the tail ends on pixel 29 and the next comet starts at offset 0. The second if turns pixel 29 off, so no light gets left behind at the wrap.

The main loop

counter = 0
clear()
...
while True:
    for color_index in range(0, color_count):
        for i in range(0, NUMBER_PIXELS):
            draw_comet_tail(counter, colors[color_index], .005)
            counter += 1

The clear() function turns every pixel off before the show starts. The loop on color_index picks each of the nine colors in turn. The loop on i moves the comet 30 times, which is one full lap.

The variable counter is the offset. It grows forever, and the % inside draw_comet_tail keeps it on the strip. A lap is exactly 30 steps, so each new color starts with the comet back at offset 0.

Each lap has 30 steps with nine pauses each. That's 1.35 seconds of pausing, plus the time to write to the strip. Nine colors take at least 12 seconds before the show repeats.

Two things to notice

The program defines a wheel() function that came from the color wheel labs. No line in this program calls it, so you can ignore it.

Look at for i in range(0, color_count) in draw_comet_tail. The tail has one pixel for each level, so level_count might seem like the right name. The loop still works because colors and levels both hold exactly nine items. The second challenge below is a puzzle about this.

Try it yourself

  1. Change the .005 in draw_comet_tail(counter, colors[color_index], .005) to .02. The comet slows down. Then try .001.
  2. This one might feel tricky at first, and that's normal. Replace the colors = ... line with these two lines to add a tenth color, then run the program.
Your change
pink = (255, 20, 147)
colors = (red, orange, yellow, green, blue, indigo, violet, cyan, white, pink)

The program stops with an IndexError, which means the code asked a list for an item that is not there. That's a puzzle to solve! Find the range(0, color_count) line in draw_comet_tail and change color_count to level_count. Then explain why the fix works.

Check your understanding

  1. What does the levels list hold? Which pixel gets level 255?
  2. What color do you get if scale is 0.5 and the color is (200, 100, 0)?
  3. When offset is 5, which pixel is the head? Which pixel is the tail end?
  4. Why does the code use % on target?
  5. How many steps make one lap for each color?

Lab complete!

Pixel celebrates You built a comet with real fading light! Scaling a color by a number between 0 and 1 is a trick you'll use again and again.

What's next: In Lab 17: Moving Rainbow with a Color List, a rainbow you build by hand slides down the strip.