The Smell of Molten Projects in the Morning

Ed Nisley's Blog: Shop notes, electronics, firmware, machinery, 3D printing, laser cuttery, and curiosities. Contents: 100% human thinking, 0% AI slop.

Tag: Improvements

Making the world a better place, one piece at a time

  • Chip-on-board LED Desk Lamp Retrofit

    After the 5 mm white LEDs failed on the original desk lamp rebuild, I picked up some chip-on-board LED lamps from the usual eBay supplier:

    COB LED Desk Lamp - bottom
    COB LED Desk Lamp – bottom

    The LED’s aluminum baseplate (perhaps there’s an actual “board” inside the yellow silicone fill) is firmly epoxied to a small heatsink from the Big Box o’ Heatsinks, chosen on the basis of being the right size and not being too battered.

    The rather limited specs say the LED supply voltage can range from 9 to 12 V, suggesting a bit of slack, with a maximum dissipation of 3 W, which definitely requires a heatsink.

    The First Light test looked promising:

     COB LED Desk Lamp - first light
    COB LED Desk Lamp – first light

    That’s driven from the same 12 VDC 200 mA wall wart that I used for the failed ring light version. Measuring the results shows that the supply now runs at the ragged edge of its current rating, with the output voltage around 10.5 V with plenty of ripple:

    COB LED V I 100ma div
    COB LED V I 100ma div

    The 260 mA current (bottom, trace 1 at 100 mA/div) varies from 200 to 300 mA as the voltage (top, trace 2 at 2 V/div) varies between 10 V and a bit under 11 V. If you believe the RMS values, it’s dissipating 2.7 W and the heatsink runs at a pleasant 105 °F in an ordinary room. The wall wart gets about as warm as you’d expect; it contains an old heavy-iron transformer and rectifier, not a trendy switcher.

    The heatsink mount looks nice, in a geeky way:

    COB LED Desk Lamp - side detail
    COB LED Desk Lamp – side detail

    The left side must be that long to anchor the gooseneck; I thought about tapering the slab a bit, but, really, it’s OK the way it is. Dabs of epoxy hold the gooseneck and heatsink in place.

    The heatsink rests on a small ledge at the bottom of the slab that’s as tall as the COB LED is thick, with a wire channel from the gooseneck socket:

    COB LED Heatsink mount - Slic3r
    COB LED Heatsink mount – Slic3r

    The Hilbert Curve infill on the top produces a textured finish; I’m a sucker for that pattern.

    The old lamp base isn’t particularly stylin’, but the new head lights up my desk below the big monitors without any glare:

    COB LED Desk Lamp - overview
    COB LED Desk Lamp – overview

    Now, let’s see how long this one lasts…

    The OpenSCAD source code as a Github gist:

    // Chip-on-board LED light heatsink mount for desk lamp
    // Ed Nisley KE4ZNU December 2015
    Layout = "Show"; // Show Build
    //- Extrusion parameters must match reality!
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    //———————-
    // Dimensions
    ID = 0; // for round things
    OD = 1;
    LENGTH = 2;
    Gooseneck = [3.0,5.0,15.0]; // anchor for end of gooseneck
    COB = [25.0,23.0,2.5]; // Chip-on-board LED module
    Heatsink = [35.5,31.5,4.0]; // height is solid base bottom
    HSWire = [23.0,28.0,53.3]; // anchor width OC, width OAL, length OC
    HSWireDia = 1.4;
    HSLip = 1.0; // width of lip under heatsink
    BaseMargin = 2*2*ThreadWidth;
    BaseRadius = Gooseneck[OD]; // 2 x gooseneck = enough anchor, sets slab thickness
    BaseSides = 2*4;
    Base = [(Gooseneck[LENGTH] + Gooseneck[OD] + Heatsink[0] + 2*BaseRadius + BaseMargin),
    (Heatsink[1] + 2*BaseRadius + 2*BaseMargin),
    2*BaseRadius];
    //———————-
    // Useful routines
    module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
    Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
    FixDia = Dia / cos(180/Sides);
    cylinder(r=(FixDia + HoleWindage)/2,
    h=Height,
    $fn=Sides);
    }
    //– Lamp heatsink mount
    module Lamp() {
    difference() {
    translate([(Base[0]/2 – BaseRadius – Gooseneck[LENGTH]),0,0])
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(Base[0]/2 – BaseRadius),j*(Base[1]/2 – BaseRadius),Base[2]/2])
    sphere(r=BaseRadius/cos(180/BaseSides),$fn=BaseSides);
    translate([(Heatsink[0]/2 + Gooseneck[OD]),0,Heatsink[2] + COB[2]]) // main heatsink recess
    scale([1,1,2])
    cube((Heatsink + [HoleWindage,HoleWindage,0.0]),center=true);
    translate([(Heatsink[0]/2 + Gooseneck[OD]),0,Heatsink[2] – Protrusion]) // lower lip to shade lamp module
    scale([1,1,2])
    cube(Heatsink – [2*HSLip,2*HSLip,0],center=true);
    translate([0,0,Base[2]/2]) // goooseneck insertion
    rotate([0,-90,0]) rotate(180/8)
    PolyCyl(Gooseneck[OD],Base[0],8);
    translate([0,0,Base[2]/2 + Gooseneck[ID]/2]) // wire exit
    rotate([180,0,0])
    PolyCyl(Gooseneck[ID],Base[2],6);
    translate([Gooseneck[OD],0,(COB[2] – Protrusion)/2]) // wire slot
    rotate([180,0,0])
    cube([2*Gooseneck[OD],Gooseneck[ID],(COB[2] + Protrusion)],center=true);
    }
    }
    //———————-
    // Build it
    if (Layout == "Show") {
    Lamp();
    }
    if (Layout == "Build") {
    }
  • Kenmore Progressive Vacuum Cleaner: Improved Suction Control

    The Suction Control slider on the handle of our shiny new Kenmore Progressive vacuum cleaner varies the speed of the howling motor in the base unit, rather than venting more or less air into the pipe. We like that, but it’s all too easy to inadvertently slide the control and never notice it, sooo I marked the default condition:

    Kenmore Progressive Vacuum - visible suction slider
    Kenmore Progressive Vacuum – visible suction slider

    Although every vacuum cleaner we’ve ever owned has touted its “quiet operation”, we always wear 30 dB ear muffs and it’s sometimes hard to tell the difference between full throttle and not quite so fast…

  • Command-line CD Ripping & Encoding

    A recent and rather battered book-on-CD posed more than the usual problems for Asunder, so I finally broke down and fiddled around with cdparanoia and lame. This has obviously been done many times before, but breaking it into two simple steps per CD makes the inevitable errors easier to find and work around.

    Invoke cdparanoia thusly to rip an entire CD into separate tracks:

    cdparanoia -B -v
    

    The files pop out sporting names like track01.cdda.wav, but they won’t be around long enough for you to develop a deep emotional attachment.

    Throw a handful of parameters at lame to convert the WAV files into tagged MP3 files:

    d=7
    for t in {01..18} ; do lame --preset tape --tt "D${d}:T${t}" --ta "Author Name" --tl "Book title" --tn "${t}/18" --tg "Audio Book" --add-id3v2 track${t}.cdda.wav D${d}-${t}.mp3 ; done
    rm track*
    

    There’s surely a way to make a double substitution work in the track sequence, but the syntax, ah, escapes me at the moment.

    You might want to not delete the WAV files until you’re happy with the MP3 results.

    In any event, that produces a sequence of MP3 files imaginatively named along the lines of D1-01.mp3, which fits neatly into the cramped LCD space available on an MP3 player.

    Your quality preferences may differ…

  • Kenmore Progressive Vacuum Cleaner vs. Classic Electrolux Dust Brush

    Vacuum cleaner dust brushes, separated by millimeters and decades:

    Kenmore vs adapted Electrolux dust brushes
    Kenmore vs adapted Electrolux dust brushes

    The bulky one on the left came with our new Kenmore Progressive vacuum cleaner. It’s fine for dust on a flat horizontal or vertical surface and totally useless for dust on actual objects. It’s supposed to snap around the handle at the end of the cleaner’s flexy hose, where it helps make the entire assembly too large and too clumsy, or on the end of the “wand”, where it’s at the wrong angle. The bonus outer shell slides around the stubby bristles in the unlikely event they’re too long for the flat surface at hand.

    The brush on the right emerged from the Box o’ Electrolux Parts that Came With The House™, must be half a century old, and consists of a cast aluminum lump with various holes milled into it, adorned with luxuriously long and flexible horsehair. Suffice it to say they don’t make ’em like that any more. Heck, they probably don’t make horses with hair like that any more, either.

    The blue plastic adapter atop the aluminum ball looks like you’d expect by now:

    Electrolux Brush Adapter
    Electrolux Brush Adapter

    The short snout fits neatly into the space available inside the ball. The abrupt ledge at the top of the snout, of course, didn’t work well; I rushed the design for a show-n-tell.

    The OpenSCAD source code (as a Github gist) bevels that ledge and tweaks the interior air channel a bit:

    // Kenmore vacuum cleaner nozzle adapters
    // Ed Nisley KE4ZNU December 2015
    // Layout options
    Layout = "LuxBrush"; // MaleFitting CoilWand FloorBrush CreviceTool ScrubbyTool LuxBrush
    //- Extrusion parameters must match reality!
    // Print with +1 shells and 3 solid layers
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    Protrusion = 0.1; // make holes end cleanly
    //———————-
    // Dimensions
    ID1 = 0; // for tapered tubes
    ID2 = 1;
    OD1 = 2;
    OD2 = 3;
    LENGTH = 4;
    OEMTube = [35.0,35.0,41.7,40.5,30.0]; // main fitting tube
    EndStop = [OEMTube[ID1],OEMTube[ID2],47.5,47.5,6.5]; // flange at end of main tube
    FittingOAL = OEMTube[LENGTH] + EndStop[LENGTH];
    $fn = 12*4;
    //———————-
    // Useful routines
    module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
    Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
    FixDia = Dia / cos(180/Sides);
    cylinder(r=(FixDia + HoleWindage)/2,
    h=Height,
    $fn=Sides);
    }
    //——————-
    // Male fitting on end of Kenmore tools
    // This slides into the end of the handle or wand and latches firmly in place
    module MaleFitting() {
    Latch = [40,11.5,5.0]; // rectangle latch opening
    EntryAngle = 45; // latch entry ramp
    EntrySides = 16;
    EntryHeight = 15.0; // lower edge on *inside* of fitting
    KeyRadius = 1.0;
    translate([0,0,6.5])
    difference() {
    union() {
    cylinder(d1=OEMTube[OD1],d2=OEMTube[OD2],h=OEMTube[LENGTH]); // main tube
    hull() // insertion guide
    for (i=[-(6.0/2 – KeyRadius),(6.0/2 – KeyRadius)],
    j=[-(28.0/2 – KeyRadius),(28.0/2 – KeyRadius)],
    k=[-(26.0/2 – KeyRadius),(26.0/2 – KeyRadius)])
    translate([(i – (OEMTube[ID1]/2 + OEMTube[OD1]/2)/2 + 6.0/2),j,(k + 26.0/2 – 1.0)])
    sphere(r=KeyRadius,$fn=8);
    translate([0,0,-EndStop[LENGTH]]) // wand tube butts against this
    cylinder(d=EndStop[OD1],h=EndStop[LENGTH] + Protrusion);
    }
    translate([0,0,-OEMTube[LENGTH]]) // main bore
    cylinder(d=OEMTube[ID1],h=2*OEMTube[LENGTH] + 2*Protrusion);
    translate([0,-11.5/2,23.0 – 5.0]) // latch opening
    cube(Latch);
    translate([OEMTube[ID1]/2 + EntryHeight/tan(90-EntryAngle),0,0]) // latch ramp
    translate([(Latch[1]/cos(180/EntrySides))*cos(EntryAngle)/2,0,(Latch[1]/cos(180/EntrySides))*sin(EntryAngle)/2])
    rotate([0,-EntryAngle,0])
    intersection() {
    rotate(180/EntrySides)
    PolyCyl(Latch[1],Latch[0],EntrySides);
    translate([-(2*Latch[0])/2,0,-Protrusion])
    cube(2*Latch[0],center=true);
    }
    }
    }
    //——————-
    // Refrigerator evaporator coil wand
    module CoilWand() {
    union() {
    translate([0,0,50.0])
    rotate([180,0,0])
    difference() {
    cylinder(d1=EndStop[OD1],d2=42.0,h=50.0);
    translate([0,0,-Protrusion])
    cylinder(d1=35.0,d2=35.8,h=100);
    }
    translate([0,0,50.0 – Protrusion])
    MaleFitting();
    }
    }
    //——————-
    // Refrigerator evaporator coil wand
    module FloorBrush() {
    union() {
    translate([0,0,60.0])
    rotate([180,0,0])
    difference() {
    union() {
    cylinder(d1=EndStop[OD1],d2=32.4,h=10.0);
    translate([0,0,10.0 – Protrusion])
    cylinder(d1=32.4,d2=30.7,h=50.0 + Protrusion);
    }
    translate([0,0,-Protrusion])
    cylinder(d1=28.0,d2=24.0,h=100);
    }
    translate([0,0,60.0 – Protrusion])
    MaleFitting();
    }
    }
    //——————-
    // Crevice tool
    module CreviceTool() {
    union() {
    translate([0,0,60.0])
    rotate([180,0,0])
    difference() {
    union() {
    cylinder(d1=EndStop[OD1],d2=32.0,h=10.0);
    translate([0,0,10.0 – Protrusion])
    cylinder(d1=32.0,d2=30.4,h=50.0 + Protrusion);
    }
    translate([0,0,-Protrusion])
    cylinder(d1=28.0,d2=24.0,h=100);
    }
    translate([0,0,60.0 – Protrusion])
    MaleFitting();
    }
    }
    //——————-
    // Mystery brush
    module ScrubbyTool() {
    union() {
    translate([0,0,60.0])
    rotate([180,0,0])
    difference() {
    union() {
    cylinder(d1=EndStop[OD1],d2=31.8,h=10.0);
    translate([0,0,10.0 – Protrusion])
    cylinder(d1=31.8,d2=31.0,h=50.0 + Protrusion);
    }
    translate([0,0,-Protrusion])
    cylinder(d1=26.0,d2=24.0,h=100);
    }
    translate([0,0,60.0 – Protrusion])
    MaleFitting();
    }
    }
    //——————-
    // Electrolux brush ball
    module LuxBrush() {
    union() {
    translate([0,0,30.0])
    rotate([180,0,0])
    difference() {
    union() {
    cylinder(d1=EndStop[OD1],d2=30.8,h=10.0);
    translate([0,0,10.0 – Protrusion])
    cylinder(d1=30.8,d2=30.0,h=20.0 + Protrusion);
    }
    translate([0,0,-Protrusion])
    cylinder(d1=25.0,d2=23.0,h=30 + 2*Protrusion);
    }
    translate([0,0,30.0 – Protrusion])
    MaleFitting();
    }
    }
    //———————-
    // Build it!
    if (Layout == "MaleFitting")
    MaleFitting();
    if (Layout == "CoilWand")
    CoilWand();
    if (Layout == "FloorBrush")
    FloorBrush();
    if (Layout == "CreviceTool")
    CreviceTool();
    if (Layout == "ScrubbyTool")
    ScrubbyTool();
    if (Layout == "LuxBrush")
    LuxBrush();

    That’s  supposed to prevent the WordPress post editors from destroying the formatting…

  • Chiplotle: Better RTS-CTS Handshake Hackage

    With hardware handshaking in full effect, the Chiplotle routine that sends data to the HP 7475A plotter doesn’t need to sleep, because the Linux serial handlers take care of that under the hood. Rather than simply comment that statement out, as I did before, it’s better to test the configuration and only sleep when needed:

    The routine that extracts values from ~/.chiplotle/config.py is already included (well, imported) in the distribution’s baseplotter.py file, so all we need is a test for (the lack of) hardware handshaking:

       def _write_string_to_port(self, data):
          ''' Write data to serial port. data is expected to be a string.'''
          #assert type(data) is str
          if not isinstance(data, basestring):
             raise TypeError('string expected.')
          data = self._filter_unrecognized_commands(data)
          data = self._slice_string_to_buffer_size(data)
          for chunk in data:
             if not get_config_value('rtscts'):
                 self._sleep_while_buffer_full( )
             self._serial_port.write(chunk)
    

    The wisdom of reading a file inside the innermost loop of the serial data output routine may be debatable, but:

    • The output is 9600 b/s serial data
    • The expected result is that we’re about to wait
    • Plenty of smart folks have improved file I/O, so the read is probably a cache hit

    For all I know, it doesn’t actually read a file, but consults an in-memory data structure. Works well enough for me, anyhow.

    The configuration file I’ve been using all along looks like this (minus most of the comments):

    # -*- coding: utf-8 -*-
    serial_port_to_plotter_map = {'/dev/ttyUSB0' : 'HP7475A'}
    
    ## Serial connection parameters.
    ## Set your plotter to match these values, or vice versa..
    baudrate = 9600
    bytesize = 8
    parity = 'N'
    stopbits = 1
    timeout = 1
    xonxoff = 0
    rtscts = 1
    
    
    ## Maximum wait time for response from plotter.
    ## Every time the plotter is queried, Chiplotle will wait for
    ## a maximum of `maximum_response_wait_time` seconds.
    maximum_response_wait_time = 4
    
    
    ## Set to True if you want information (such as warnings)
    ## displayed on the console. Set to False if you don't.
    verbose = True
    

    That’s much prettier…

  • Hard Drive Platter Mood Light: Correct Phase Timing

    As noted earlier, the timing for a π/16 phase delay works out to

    218 steps = (π/16) * (1 cycle/2π) * (7 * 1000 step/cycle)

    which amounts to a delay of 5.45 s = 218 step * 25 ms/step. That means a color should appear on the top platter 11 s after it appears on the bottom platter:

    Mood Light - pi over 16 phase - composite
    Mood Light – pi over 16 phase – composite

    But when I actually got out a stopwatch and timed the colors, the bottom-to-top delay worked out to a mere 3.5 s…

    After establishing that the steps ticked along at the expected 25 ms pace, the phase-to-step calculation produced the right answer, the increments were working as expected, I finally slept on the problem (a few times, alas) and realized that the increment happened in the wrong place:

    for (int i=0; i < LEDSTRINGCOUNT; i++) { // for each layer byte Value[PIXELSIZE]; for (byte c=0; c > PIXELSIZE; c++) { // figure the new PWM values if (++Pixels[c].Step >= Pixels[c].NumSteps) {   //  ... from incremented step
                Pixels[c].Step = 0;
            }
            Value[c] = StepColor(c,-i*Pixels[c].PlatterPhase);
        }
        uint32_t UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE]);
     
        for (int j=0; j < LEDSTRIPCOUNT; j++) {              // fill layer with color
            strip.setPixelColor(Map[i][j],UniColor);
        }
    }
    

    The outer loop runs “for each layer”, so the increment happens three times on each step, making the colors shift three times faster than they should.

    Promoting the increments to their own loop solved the problem:

    	MillisNow = millis();
    	if ((MillisNow - MillisThen) > UpdateMS) {
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		
    		for (byte c=0; c < PIXELSIZE; c++) { // step to next increment in each color if (++Pixels[c].Step >= Pixels[c].NumSteps) {
    				Pixels[c].Step = 0;
    				printf("Cycle %d steps %d at %8ld delta %ld ms\r\n",c,Pixels[c].NumSteps,MillisNow,(MillisNow - MillisThen));
    			}
    		}
    
    		for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    			byte Value[PIXELSIZE];
    			for (byte c=0; c < PIXELSIZE; c++) {				//  ... for each color
    				Value[c] = StepColor(c,-i*Pixels[c].PlatterPhase);		// figure new PWM value
    //				Value[c] = (c == RED && Value[c] == 0) ? Pixels[c].MaxPWM : Value[c];	// flash highlight for tracking
    			}
    			uint32_t UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE]);
    			if (false && (i == 0))
    				printf("L: %d C: %08lx\r\n",i,UniColor);
    			for (int j=0; j < LEDSTRIPCOUNT; j++) {				// fill layer with color
    				strip.setPixelColor(Map[i][j],UniColor);
    			}
    		}
    		strip.show();
    
    		MillisThen = MillisNow;
    		digitalWrite(PIN_HEARTBEAT,LOW);
    	}
    

    And then It Just Worked.

    Verily, it is written: One careful measurement trumps a thousand expert opinions.

    Sheesh

    (The WordPress editor wrecked these code snippets. I’m leaving them broken so WP can maybe fix the problem.) The problem isn’t fixed, but these are OK now… as long as I don’t unleash the “improved” editor on the post, anyway.

  • Hard Drive Platter Mood Light: Color Gradations

    Now that the trig argument runs from 0 through 2π and resets for each complete cycle, it’s practical to add a phase that changes the colors on a per-layer basis.

    The first trick, filling each layer with a single color, requires a two-dimensional Map array that lists the pixels in the proper order:

    // number of LED strips around hub
    #define LEDSTRIPCOUNT 4
    
    // number of LEDs per strip
    #define LEDSTRINGCOUNT 3
    
    byte Map[LEDSTRINGCOUNT][LEDSTRIPCOUNT] = {{0,5,6,11}, {1,4,7,10}, {2,3,8,9}};	// pixel IDs around platter, bottom to top.
    

    Instantiate the Adafruit library buffer, as before, but now compute the proper number of pixels from the fundamental constants:

    Adafruit_NeoPixel strip = Adafruit_NeoPixel(LEDSTRIPCOUNT * LEDSTRINGCOUNT, PIN_NEO, NEO_GRB + NEO_KHZ800);
    

    You can still access the pixel buffer using a linear index, which the first part of the lamp test uses to walk a single white pixel through the string in the natural wiring order:

    	strip.setPixelColor(0,FullWhite);
    	strip.show();
    	delay(500);
    	
    	for (int i=1; i<strip.numPixels(); i++) {
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		strip.setPixelColor(i-1,FullOff);
    		strip.setPixelColor(i,FullWhite);
    		strip.show();
    		digitalWrite(PIN_HEARTBEAT,LOW);
    		delay(500);
    	}
    

    Then fill them with white, layer by layer from the bottom up, using the Map array:

    	for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		for (int j=0; j < LEDSTRIPCOUNT; j++) {				// spread color around the layer
    			strip.setPixelColor(Map[i][j],FullWhite);
    			strip.show();
    			delay(250);
    		}
    		digitalWrite(PIN_HEARTBEAT,LOW);
    	}
    

    With that in hand, it took me a disturbing amount of time to figure out that the angular phase should apply to the slowest sine wave, with the two other phase angles being calculated from the corresponding number of time steps. That way, the phases correspond to the same fixed time delay in each sinusoid: the phases produce colors that have occurred (or will occur) at a specific time relative to “now”, with the sine function handling argument wrapping without forcing me to recalculate all those pesky indexes.

    The PlatterSteps variable holds the number of steps in the BASEPHASE angle in the slowest wave:

    	Pixels[RED].Prime = 3;
    	Pixels[GREEN].Prime = 5;
    	Pixels[BLUE].Prime = 7;
    	
    	PlatterSteps = (unsigned int) ((BASEPHASE / TWO_PI) *
    				RESOLUTION * (unsigned int) max(max(Pixels[RED].Prime,Pixels[GREEN].Prime),Pixels[BLUE].Prime));
    

    En passant, I set the PWM limits that keep the LED temperature under control, then compute the per-color values:

    	Pixels[RED].MaxPWM = 64;
    	Pixels[GREEN].MaxPWM = 64;
    	Pixels[BLUE].MaxPWM = 64;
    	
    	for (byte c=0; c < PIXELSIZE; c++) {
    		Pixels[c].NumSteps = RESOLUTION * (unsigned int) Pixels[c].Prime;
    		Pixels[c].Step = (false) ? random(Pixels[c].NumSteps) : Pixels[c].NumSteps - 1;
    		Pixels[c].StepSize = TWO_PI / Pixels[c].NumSteps;				// in radians per step
    		Pixels[c].PlatterPhase = PlatterSteps * Pixels[c].StepSize;		// radians per platter
    	}
    

    Most of the type promotions / conversions / coercions among bytes / integers / floats happen without much attention, but every now & again I faceplanted one.

    Whenever it’s time for an update (every 25 ms seems OK), this code computes the new color for each layer and spreads it around:

    		for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    			byte Value[PIXELSIZE];
    			for (byte c=0; c > PIXELSIZE; c++) { // figure the new PWM values if (++Pixels[c].Step >= Pixels[c].NumSteps) {	//  ... from incremented step
    					Pixels[c].Step = 0;
    				}
    				Value[c] = StepColor(c,-i*Pixels[c].PlatterPhase);
    			}
    			uint32_t UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE]);
    
    			for (int j=0; j < LEDSTRIPCOUNT; j++) {				// fill layer with color
    				strip.setPixelColor(Map[i][j],UniColor);
    			}
    		}
    

    The -i*Pixels[c].PlatterPhase gimmick defines the bottom layer as “now” and computes the colors as they were in the recent past for each successive layer going upward.

    With the phase difference boosted to π/4 to make the differences more visible:

    Mood Light - pi over 4 phase
    Mood Light – pi over 4 phase

    You’re seeing three LEDs reflected in the platters, of course.

    A phase difference of π/16 seems barely visible in this composite image,but it’s pleasant in person:

    Mood Light - pi over 16 phase - composite
    Mood Light – pi over 16 phase – composite

    The greenish ones come from a slightly different perspective. The purple ones show the progression over the course of a few seconds.

    A π/16 = 11.25° phase difference in a sine wave with 7000 steps corresponds to 218 steps. At 25 ms/step, that’s a 5.5 s delay and the top layer duplicates the bottom layer after 11 s.

    It’s surprisingly relaxing…

    The complete Arduino source code:

    // Neopixel mood lighting for hard drive platter sculpture
    // Ed Nisley - KE4ANU - December 2015
    
    #include <Adafruit_NeoPixel.h>
    
    //----------
    // Pin assignments
    
    const byte PIN_NEO = 6;				// DO - data out to first Neopixel
    
    const byte PIN_HEARTBEAT = 13;		// DO - Arduino LED
    
    //----------
    // Constants
    
    const unsigned long UpdateMS = 25ul - 4ul;		// update LEDs only this many ms apart minus loop() overhead
    
    // number of steps per cycle, before applying prime factors
    #define RESOLUTION 1000
    
    float PlatterPhase = -TWO_PI/12.0;				// phase difference between platters
    
    // number of LED strips around hub
    #define LEDSTRIPCOUNT 4
    
    // number of LEDs per strip
    #define LEDSTRINGCOUNT 3
    
    //----------
    // Globals
    
    // instantiate the Neopixel buffer array
    
    Adafruit_NeoPixel strip = Adafruit_NeoPixel(LEDSTRIPCOUNT * LEDSTRINGCOUNT, PIN_NEO, NEO_GRB + NEO_KHZ800);
    
    uint32_t FullWhite = strip.Color(255,255,255);
    uint32_t FullOff = strip.Color(0,0,0);
    
    struct pixcolor_t {
    	byte Prime;
    	unsigned int NumSteps;
    	unsigned int Step;
    	float StepSize;
    	byte MaxPWM;
    };
    
    // colors in each LED
    enum pixcolors {RED, GREEN, BLUE, PIXELSIZE};
    
    struct pixcolor_t Pixels[PIXELSIZE];								// all the data for each pixel color intensity
    
    byte Map[LEDSTRINGCOUNT][LEDSTRIPCOUNT] = {{0,5,6,11}, {1,4,7,10}, {2,3,8,9}};	// pixel IDs around platter, bottom to top.
    
    unsigned long MillisNow;
    unsigned long MillisThen;
    
    //-- Figure PWM based on current state
    
    byte StepColor(byte Color, float Phi) {
    
    byte Value;
    
        Value = (Pixels[Color].MaxPWM / 2.0) * (1.0 + sin(Pixels[Color].Step * Pixels[Color].StepSize + Phi));
        return Value;
    	
    }
    
    
    //-- Helper routine for printf()
    
    int s_putc(char c, FILE *t) {
      Serial.write(c);
    }
    
    //------------------
    // Set the mood
    
    void setup() {
    	
    	pinMode(PIN_HEARTBEAT,OUTPUT);
    	digitalWrite(PIN_HEARTBEAT,LOW);	// show we arrived
    
    	Serial.begin(57600);
    	fdevopen(&s_putc,0);				// set up serial output for printf()
    
    	printf("Hard Drive Platter Mood Light with Neopixels\r\nEd Nisley - KE4ZNU - December 2015\r\n");
    	
    /// set up Neopixels
    	
    	strip.begin();
    	strip.show();
    	
    // lamp test: run a brilliant white dot along the length of the strip
    	
    	printf("Lamp test: walking white\r\n");
    	
    	strip.setPixelColor(0,FullWhite);
    	strip.show();
    	delay(500);
    	
    	for (int i=1; i<strip.numPixels(); i++) {
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		strip.setPixelColor(i-1,FullOff);
    		strip.setPixelColor(i,FullWhite);
    		strip.show();
    		digitalWrite(PIN_HEARTBEAT,LOW);
    		delay(500);
    	}
    	
    	strip.setPixelColor(strip.numPixels() - 1,FullOff);
    	strip.show();
    	delay(500);
    	
    // fill the layers
    	
    	printf(" ... fill using Map array\r\n");
    	
    	for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		for (int j=0; j < LEDSTRIPCOUNT; j++) {				// spread color around the layer
    			strip.setPixelColor(Map[i][j],FullWhite);
    			strip.show();
    			delay(250);
    		}
    		digitalWrite(PIN_HEARTBEAT,LOW);
    	}
    	
    // clear to black
    	
    	printf(" ... clear\r\n");
    	
    	for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    		for (int j=0; j < LEDSTRIPCOUNT; j++) {				// spread color around the layer
    			strip.setPixelColor(Map[i][j],FullOff);
    			strip.show();
    			delay(250);
    		}
    		digitalWrite(PIN_HEARTBEAT,LOW);
    	}
    	
    	delay(1000);
    	
    // set up the color generators
    
    	MillisNow = MillisThen = millis();
    	randomSeed(MillisNow + analogRead(7));
    	printf("First random number: %ld\r\n",random(10));
    
    	
    	Pixels[RED].Prime = 7;
    	Pixels[GREEN].Prime = 11;
    	Pixels[BLUE].Prime = 5;
    	
    	Pixels[RED].MaxPWM = 64;
    	Pixels[GREEN].MaxPWM = 64;
    	Pixels[BLUE].MaxPWM = 64;
    	
    	for (byte c=0; c < PIXELSIZE; c++) {
    		Pixels[c].NumSteps = RESOLUTION * (unsigned int) Pixels[c].Prime;
    		Pixels[c].Step = (true) ? random(Pixels[c].NumSteps) : Pixels[c].NumSteps - 1;
    		Pixels[c].StepSize = TWO_PI / Pixels[c].NumSteps;
    	}
    	
    	printf("Prime scales: (%d,%d,%d)\r\n",Pixels[RED].Prime,Pixels[GREEN].Prime,Pixels[BLUE].Prime);
    	printf("Initial step: (%d,%d,%d)\r\n",Pixels[RED].Step,Pixels[GREEN].Step,Pixels[BLUE].Step);
    	printf("Max PWM: (%d,%d,%d)\r\n",Pixels[RED].MaxPWM,Pixels[GREEN].MaxPWM,Pixels[BLUE].MaxPWM);
    	printf("Platter phase: %d deg\r\n",(int)(360.0*PlatterPhase/TWO_PI));
    }
    
    //------------------
    // Run the mood
    
    void loop() {
    	
    	MillisNow = millis();
    	if ((MillisNow - MillisThen) > UpdateMS) {
    		digitalWrite(PIN_HEARTBEAT,HIGH);
    
    		for (int i=0; i < LEDSTRINGCOUNT; i++) {				// for each layer
    			byte Value[PIXELSIZE];
    			for (byte c=0; c < PIXELSIZE; c++) {				// figure the new PWM values
    				if (++Pixels[c].Step >= Pixels[c].NumSteps) {	//  ... from incremented step
    					Pixels[c].Step = 0;
    				}
    				Value[c] = StepColor(c,i*PlatterPhase);
    			}
    			uint32_t UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE]);
    			if (false && (i == 0))
    				printf("C: %08lx\r\n",UniColor);
    			for (int j=0; j < LEDSTRIPCOUNT; j++) {				// fill layer with color
    				strip.setPixelColor(Map[i][j],UniColor);
    			}
    		}
    		strip.show();
    
    		MillisThen = MillisNow;
    		digitalWrite(PIN_HEARTBEAT,LOW);
    	}
    	
    }
    

    Apart from the thermal problems, it’s pretty slick…

    [Edit: if you look carefully, you’ll find a not particularly subtle error that completely screws up the timing. The LEDs looks great and work as described, but the colors run too fast. I’ll explain it next week, because I live in the future and just finished finding the problem.]