I like to mix hobbies, so naturally I’ve been eying astrophotography for a while. I’ve taken a time-lapse here and a moon picture there but, inspired by the folks over at /r/astrophotography, I wanted to take it to the next level. Since the Earth is spinning, any long exposure of the night sky has star trails, so you have to make your camera counter-spin if you want clear shots. In this post, you can read about how I made a simple barn door sky tracker to do this.

Barn door sky trackers have been made at home by lots of people for a long time. There are a variety of designs with different levels of complexity and precision required. I thought I’d make the simplest-to-construct one, a Haig mount. To correct he tangent error, I decided to use a cheap microcontroller (MCU) and have it speed up appropriately via software. Fun!

The Math

The math behind this is fun mostly because it’s straight out of high school and you finally at long last get to use it. Here’s the basic design:

The angle must increase at a constant rate based on the rotation of the Earth. We all know the rate the world turns so we can get explicit expressions for the angle as a function of time.

Since the Earth is also rotating around the Sun, it turns out that it actually takes more like 23 hours, 56 minutes, 4.0916 seconds for the stars in the sky to rotate all the way around. This is called the Mean Sidereal Day and is the value we’ll actually use in this system. As you can now calculate, the constant angular velocity we need this thing to open at is 7.292115e-05 radians per second.

Now we are faced with a classic word problem: “What rate should we spin the motor to maintain a constant angular velocity in the above triangle?” First let’s just see how fast the screw has to rise. Bust out some trig to get an expression for y(t) and then take its derivative (don’t forget the chain rule!) and you’ll find:

Ok, now we just have to convert that to a rotation of the screw. The threaded rod I got at Hero Ace downtown is a 1/4-20 bolt, where the 20 means “there are 20 threads in 1 inch,” or in other words, “there are 20 rotations per 2.54 cm of insertion = 7.874 rotations/cm.” Adjust accordingly if you have a different screw thread.

Let’s plot the rotations per minute required to match the Earth’s rotation for varying values of L my multiplying dy/dt [cm/minute] by 7.874 [rotations/cm] for 90 minutes.

Note: You can check out the Python code used to do these calculations here.

As you can see, the rate changes with time! This is called “the tangent error” and it’s caused by the fact that the acorn nut is sliding along the hypotenuse as the “barn door” opens (see schematic above). There are a bunch of ways people have dealt with it. One is to use a curved screw. Another is to put a specially-shaped cam where the acorn nut contacts the top board. But for this project, I’m simply going to speed up the motor as time goes on if I do a very long exposure. Not having a proper shop, I prefer to use simple physical construction and deal with it in code. If you’re doing 5-10 minute exposures, this issue won’t get to you. Note also that if you have a constant 1 RPM motor, a 29cm L is about right for you.

Putting it together

I bought some stuff at the local ACE hardware store to put this together. They had pretty much everything I needed in the hardware department.

My steps:

Cut boards to size. I chose my total board length as a few inches longer than the 29cm I wanted between my hinge and the shaft. Note that you want to give the top board enough space to open and still contact the shaft, so give it at least 2-3 inches, and longer if you want even longer exposures.

Attach hinge

Attach slide plate if you have one (not sure if needed)

Measure out L from hinge pin and drill hole in bottom board

Apply epoxy to tee-nut for drive shaft and hammer it in place

Epoxy small washers to the top of rails

Stick motor shaft in drive hole and mark location of rails

Drill 1/8″ holes for 1/8″ rails on bottom board and slide them in

Drill hole for ball mount on top board

Mount camera and have a friend help balance it to try to figure out where the center of gravity is on the bottom board. Choose a spot and drill and install 2nd tee-nut for bottom tripod mount. I ended up moving mine from the bottom to the top (so the tripod screw pulls it into the wood, not out) and getting a really long tee-nut for better stability. Then I can really crank down on the tripod screw so the thing stays steady.

Drill out 1/4″ hole in one side of 5mm coupling nut to attach drive shaft to motor

Screw threaded rod into main hole, install acorn nut on top

Put motor on tails and fire it up!

Loud video warning:





Programming the Stepper Motor/MCU

Before I started this project, I happened to have a bag full of stepper motors (like these) and their controllers, and also a bag full of ESP8266 microcontrollers (like these). These are both extremely cheap (like $5 each) items and so I figured they’d be fun to apply for this. ESP8266’s even have Wifi if you want to get super fancy but I didn’t use that here.

NOTE: If you’d rather not bother with a MCU and variable-speed motor, you can do very well just skipping this step and using a much simpler 1 RPM motor and a L of 29 cm.

Using instructions from here, I got my ESP8266 all hooked up and ready to program using the Arduino IDE. Here’s a good intro to spinning these kinds of motors with code.

Calibration of the ESP8266 delay

First thing I wanted to do was just double check that the timing would be precise enough for what I need (mostly I just wanted to fire up the scope). I got the motor turning with a 5 ms delay, double-stepping and took this measurement from one of the 4 pins:

Pretty close to that 10 ms we’d expect. Looks good to me. Here’s the code:

Code for ESP8266 and stepper motor spinning at constant rate #define NUM_PINS 4 #define NUM_STEPS 8 int allPins[NUM_PINS] = {D1, D2, D3, D4}; // from manufacturers datasheet int STEPPER_SEQUENCE[NUM_STEPS][NUM_PINS] = {{1,0,0,1}, {1,0,0,0}, {1,1,0,0}, {0,1,0,0}, {0,1,1,0}, {0,0,1,0}, {0,0,1,1}, {0,0,0,1}}; int stepNum = 0; void setup() { Serial.begin(115200); setup_gpio(); } void setup_gpio() { for (int i=0;i<NUM_PINS+1;i++) { pinMode(allPins[i], OUTPUT); } all_pins_off(); } void all_pins_off() { for (int i=0;i<NUM_PINS+1;i++) { digitalWrite(allPins[i], HIGH); } } int *currentStep; void loop() { currentStep = STEPPER_SEQUENCE[stepNum]; for (int i=0;i<NUM_PINS+1;i++) { if (currentStep[i] == 1) { digitalWrite(allPins[i], HIGH); } else { digitalWrite(allPins[i], LOW); } } delay(5); stepNum +=2; // double-stepping. Faster and shakier. stepNum %= NUM_STEPS; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 #define NUM_PINS 4 #define NUM_STEPS 8 int allPins [ NUM_PINS ] = { D1 , D2 , D3 , D4 } ; // from manufacturers datasheet int STEPPER_SEQUENCE [ NUM_STEPS ] [ NUM_PINS ] = { { 1 , 0 , 0 , 1 } , { 1 , 0 , 0 , 0 } , { 1 , 1 , 0 , 0 } , { 0 , 1 , 0 , 0 } , { 0 , 1 , 1 , 0 } , { 0 , 0 , 1 , 0 } , { 0 , 0 , 1 , 1 } , { 0 , 0 , 0 , 1 } } ; int stepNum = 0 ; void setup ( ) { Serial . begin ( 115200 ) ; setup_gpio ( ) ; } void setup_gpio ( ) { for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { pinMode ( allPins [ i ] , OUTPUT ) ; } all_pins_off ( ) ; } void all_pins_off ( ) { for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { digitalWrite ( allPins [ i ] , HIGH ) ; } } int * currentStep ; void loop ( ) { currentStep = STEPPER_SEQUENCE [ stepNum ] ; for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { if ( currentStep [ i ] == 1 ) { digitalWrite ( allPins [ i ] , HIGH ) ; } else { digitalWrite ( allPins [ i ] , LOW ) ; } } delay ( 5 ) ; stepNum += 2 ; // double-stepping. Faster and shakier. stepNum %= NUM_STEPS ; }

Timers, user input, and the accelerating motor

Now for the real deal, let’s code up what we need to do to correct the tangent error and have other practicalities. I was using barebones code for testing and realized it was a pain to reverse my motor (I had to remove wires and rails and twist it by hand. So I decided to add a button to control reversing and stuff.

The motor starts up when you power it up and does its thing. If you press the button once, it reverses all the way back to exactly the starting point (it remembers!), then it stops. If you press the button again, it starts back up.

The delay call in the code above is fine and all, but when the loop step is more complicated (i.e. computing our changing rate), there’s a chance we will not get the right timing. For these kinds of things, there are timers and interrupts. Basically, you write a callback function and register it to be called whenever a specific timer times out. Check it out: (update: this code is available on github)

The barn-door tracker ESP8266 stepper motor code #define NUM_PINS 4 #define NUM_STEPS 8 #define RADS_PER_SEC 7.292115e-05 #define LENGTH_CM 29.113 // fill in with precise measured value #define THETA0 0.0241218 // fill in with angle at fully closed position (radians) #define ROTATIONS_PER_CM 7.8740157 // 1/4-20 thread #define DOUBLESTEPS_PER_ROTATION 2048.0 #define CYCLES_PER_SECOND 80000000 //modes #define NORMAL 0 #define REWINDING 1 #define STOPPED 2 int allPins[NUM_PINS] = {D1, D2, D3, D4}; int MODE_PIN = D7; // from manufacturers datasheet int STEPPER_SEQUENCE[NUM_STEPS][NUM_PINS] = {{1,0,0,1}, {1,0,0,0}, {1,1,0,0}, {0,1,0,0}, {0,1,1,0}, {0,0,1,0}, {0,0,1,1}, {0,0,0,1}}; int step_delta; int step_num = 0; double total_seconds = 0.0; long totalsteps = 0; double step_interval_s=0.001; int *current_step; volatile unsigned long next; // next time to trigger callback volatile unsigned long now; // volatile keyword required when things change in callbacks volatile unsigned long last_toggle; // for debounce volatile short current_mode=NORMAL; bool autostop=true; // hack for allowing manual rewind at boot float ypt(float ts) { // bolt insertion rate in cm/s: y'(t) // Note, if you run this for ~359 minutes, it goes to infinity!! return LENGTH_CM * RADS_PER_SEC/pow(cos(THETA0 + RADS_PER_SEC * ts),2); } void inline step_motor(void) { /* This is the callback function that gets called when the timer * expires. It moves the motor, updates lists, recomputes * the step interval based on the current tangent error, * and sets a new timer. */ switch(current_mode) { case NORMAL: step_interval_s = 1.0/(ROTATIONS_PER_CM * ypt(total_seconds)* 2 * DOUBLESTEPS_PER_ROTATION); step_delta = 1; // single steps while filming for smoothest operation and highest torque step_num %= NUM_STEPS; break; case REWINDING: // fast rewind step_interval_s = 0.0025; // can often get 2ms but gets stuck sometimes. step_delta = -2; // double steps going backwards for speed. if (step_num<0) { step_num+=NUM_STEPS; // modulus works here in Python it goes negative in C. } break; case STOPPED: step_interval_s = 0.2; // wait a bit to conserve power. break; } if (current_mode!=STOPPED) { total_seconds += step_interval_s; // required for tangent error current_step = STEPPER_SEQUENCE[step_num]; do_step(current_step); step_num += step_delta; // double-steppin' totalsteps += step_delta; } // Serial.println(totalsteps); // Before setting the next timer, subtract out however many // clock cycles were burned doing all the work above. now = ESP.getCycleCount(); next = now + step_interval_s * CYCLES_PER_SECOND - (now-next); // will auto-rollover. timer0_write(next); // see you next time! } void do_step(int *current_step) { /* apply a single step of the stepper motor on its pins. */ for (int i=0;i<NUM_PINS+1;i++) { if (current_step[i] == 1) { digitalWrite(allPins[i], HIGH); } else { digitalWrite(allPins[i], LOW); } } } void setup() { Serial.begin(115200); setup_gpio(); setup_timer(); // Convenient Feature: Hold button down during boot to do a manual rewind. // Press button again to set new zero point. int buttonUp = digitalRead(MODE_PIN); if(not buttonUp) { Serial.println("Manual REWIND!"); autostop=false; current_mode=REWINDING; } } void setup_timer() { noInterrupts(); timer0_isr_init(); timer0_attachInterrupt(step_motor); // call this function when timer expires next=ESP.getCycleCount()+1000; timer0_write(next); // do first call in 1000 clock cycles. interrupts(); } void setup_gpio() { for (int i=0;i<NUM_PINS+1;i++) { pinMode(allPins[i], OUTPUT); } all_pins_off(); // Setup toggle button for some user input. pinMode(MODE_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(MODE_PIN), toggle_mode, FALLING); } void all_pins_off() { for (int i=0;i<NUM_PINS+1;i++) { digitalWrite(allPins[i], LOW); } } void toggle_mode() { /* We have several modes that we can toggle between with a button, * NORMAL, REWIND, and STOPPED. */ if(ESP.getCycleCount() - last_toggle < 0.2*CYCLES_PER_SECOND) //debounce { return; } if (current_mode == REWINDING){ Serial.println("STOPPING"); current_mode = STOPPED; all_pins_off(); if (not autostop) { // Reset things after a manual rewind. step_num = 0; total_seconds = 0.0; totalsteps=0; autostop=true; } } else if (current_mode == NORMAL) { Serial.println("Rewinding."); current_mode = REWINDING; } else { Serial.println("Restarting."); current_mode = NORMAL; } last_toggle = ESP.getCycleCount(); } void loop() { if(current_mode == REWINDING) { // we've reached the starting point. stop rewinding. if(totalsteps < 1 and autostop==true){ Serial.println("Ending the rewind and stopping."); current_mode=STOPPED; all_pins_off(); } } else { // no-op. just wait for interrupts. yield(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 #define NUM_PINS 4 #define NUM_STEPS 8 #define RADS_PER_SEC 7.292115e-05 #define LENGTH_CM 29.113 // fill in with precise measured value #define THETA0 0.0241218 // fill in with angle at fully closed position (radians) #define ROTATIONS_PER_CM 7.8740157 // 1/4-20 thread #define DOUBLESTEPS_PER_ROTATION 2048.0 #define CYCLES_PER_SECOND 80000000 //modes #define NORMAL 0 #define REWINDING 1 #define STOPPED 2 int allPins [ NUM_PINS ] = { D1 , D2 , D3 , D4 } ; int MODE_PIN = D7 ; // from manufacturers datasheet int STEPPER_SEQUENCE [ NUM_STEPS ] [ NUM_PINS ] = { { 1 , 0 , 0 , 1 } , { 1 , 0 , 0 , 0 } , { 1 , 1 , 0 , 0 } , { 0 , 1 , 0 , 0 } , { 0 , 1 , 1 , 0 } , { 0 , 0 , 1 , 0 } , { 0 , 0 , 1 , 1 } , { 0 , 0 , 0 , 1 } } ; int step_delta ; int step_num = 0 ; double total_seconds = 0.0 ; long totalsteps = 0 ; double step_interval_s = 0.001 ; int * current_step ; volatile unsigned long next ; // next time to trigger callback volatile unsigned long now ; // volatile keyword required when things change in callbacks volatile unsigned long last_toggle ; // for debounce volatile short current_mode = NORMAL ; bool autostop = true ; // hack for allowing manual rewind at boot float ypt ( float ts ) { // bolt insertion rate in cm/s: y'(t) // Note, if you run this for ~359 minutes, it goes to infinity!! return LENGTH_CM * RADS_PER_SEC / pow ( cos ( THETA0 + RADS_PER_SEC * ts ) , 2 ) ; } void inline step_motor ( void ) { /* This is the callback function that gets called when the timer * expires. It moves the motor, updates lists, recomputes * the step interval based on the current tangent error, * and sets a new timer. */ switch ( current_mode ) { case NORMAL : step_interval_s = 1.0 / ( ROTATIONS_PER_CM * ypt ( total_seconds ) * 2 * DOUBLESTEPS_PER_ROTATION ) ; step_delta = 1 ; // single steps while filming for smoothest operation and highest torque step_num %= NUM_STEPS ; break ; case REWINDING : // fast rewind step_interval_s = 0.0025 ; // can often get 2ms but gets stuck sometimes. step_delta = - 2 ; // double steps going backwards for speed. if ( step_num < 0 ) { step_num += NUM_STEPS ; // modulus works here in Python it goes negative in C. } break ; case STOPPED : step_interval_s = 0.2 ; // wait a bit to conserve power. break ; } if ( current_mode != STOPPED ) { total_seconds += step_interval_s ; // required for tangent error current_step = STEPPER_SEQUENCE [ step_num ] ; do_step ( current_step ) ; step_num += step_delta ; // double-steppin' totalsteps += step_delta ; } // Serial.println(totalsteps); // Before setting the next timer, subtract out however many // clock cycles were burned doing all the work above. now = ESP . getCycleCount ( ) ; next = now + step_interval_s * CYCLES_PER_SECOND - ( now - next ) ; // will auto-rollover. timer0_write ( next ) ; // see you next time! } void do_step ( int * current_step ) { /* apply a single step of the stepper motor on its pins. */ for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { if ( current_step [ i ] == 1 ) { digitalWrite ( allPins [ i ] , HIGH ) ; } else { digitalWrite ( allPins [ i ] , LOW ) ; } } } void setup ( ) { Serial . begin ( 115200 ) ; setup_gpio ( ) ; setup_timer ( ) ; // Convenient Feature: Hold button down during boot to do a manual rewind. // Press button again to set new zero point. int buttonUp = digitalRead ( MODE_PIN ) ; if ( not buttonUp ) { Serial . println ( "Manual REWIND!" ) ; autostop = false ; current_mode = REWINDING ; } } void setup_timer ( ) { noInterrupts ( ) ; timer0_isr_init ( ) ; timer0_attachInterrupt ( step_motor ) ; // call this function when timer expires next = ESP . getCycleCount ( ) + 1000 ; timer0_write ( next ) ; // do first call in 1000 clock cycles. interrupts ( ) ; } void setup_gpio ( ) { for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { pinMode ( allPins [ i ] , OUTPUT ) ; } all_pins_off ( ) ; // Setup toggle button for some user input. pinMode ( MODE_PIN , INPUT_PULLUP ) ; attachInterrupt ( digitalPinToInterrupt ( MODE_PIN ) , toggle_mode , FALLING ) ; } void all_pins_off ( ) { for ( int i = 0 ; i < NUM_PINS + 1 ; i ++ ) { digitalWrite ( allPins [ i ] , LOW ) ; } } void toggle_mode ( ) { /* We have several modes that we can toggle between with a button, * NORMAL, REWIND, and STOPPED. */ if ( ESP . getCycleCount ( ) - last_toggle < 0.2 * CYCLES_PER_SECOND ) //debounce { return ; } if ( current_mode == REWINDING ) { Serial . println ( "STOPPING" ) ; current_mode = STOPPED ; all_pins_off ( ) ; if ( not autostop ) { // Reset things after a manual rewind. step_num = 0 ; total_seconds = 0.0 ; totalsteps = 0 ; autostop = true ; } } else if ( current_mode == NORMAL ) { Serial . println ( "Rewinding." ) ; current_mode = REWINDING ; } else { Serial . println ( "Restarting." ) ; current_mode = NORMAL ; } last_toggle = ESP . getCycleCount ( ) ; } void loop ( ) { if ( current_mode == REWINDING ) { // we've reached the starting point. stop rewinding. if ( totalsteps < 1 and autostop == true ) { Serial . println ( "Ending the rewind and stopping." ) ; current_mode = STOPPED ; all_pins_off ( ) ; } } else { // no-op. just wait for interrupts. yield ( ) ; } }

(That bit about setting the timer at the end of the callback took some trial-and-error… it kept crashing around 4.2 billion clock cycles. Turns out that’s the max unsigned long int value. Neat. )

Portable use and Power Consumption

Stars are better when it’s darker, and that often requires remote operation. This thing runs off a USB port so if you have one of those external batteries, that should work. I tried a few options here, including a big deep-cycle battery that I use for ham radio for the really long hauls.

Through a 12V-to-5V converter, I measured 0.189 Amps, yielding 2.27 Watts. So my huge 20Ah battery could run it for 4 full days. More practically, 3 of those common lightweight Li-ion 18650s chained together would run it for over 2 days (wow). So that’s nice.

Polar alignment

It’s important to get everything aligned so it works right. You have to make sure:

Your finderscope is aligned with the hinge axis (swing it and make sure a faraway thing like a star stays in the middle).

Your finderscope is pointed at the celestial pole (close to Polaris, but not right on it)

I set a straw on my hinge and aimed it at a tower crane in the distance. Then I pointed the camera at the same crane and rotated it. With some adjustment it didn’t rotate too much, so at that point I could point the camera at Polaris, lock in the tripod, and be off.

For the longer term, I ordered one of those red-dot things from the internets because my dad got one years ago for his telescope and it was great. Once get it aligned I can just point-and-go.

UPDATE: I got it (see picture in angular calibration section below) and it is perfect.

First attempts at using

I tried it out with the straw-on-the-hinge alignment and got it sort-of aligned, to the point that I couldn’t wait to turn it on and try it. This was from downtown downtown Seattle so the light pollution was pretty intense. Fortunately I shot in RAW and could get some stars out of it.

Test number 1: It works!

Then I just pointed it around and did what I could.

Not bad. It wasn’t perfectly aligned so I’ll do more serious tests once I get that done and head to some actual dark sky.

UPDATE 2017: I used it on the eclipse and it worked great. It was nice to have the camera clicking away with exposure bracketing so I could watch the eclipse myself. I’m really happy with the result.

UPDATE 2020: I used it on C/2020 NEOWISE F3 comet and it worked amazingly well.

Precise Angular Velocity Calibration

Just to check my math a bit more, I got a digital level and figured out how to get it to dump data to my PC. Then I let it run with my tracker for a while and did some least-squares fitting to see how it was working.

So I measured a nice and constant 7.255e-5 radians/second over 10 minutes. That’s within 0.5% of the right answer. I can now adjust my target in software to speed it up by 0.5% and get it really accurate! The calibration software is also available on github.

Future work

Get a telescope. Learn how to stack photos.

Some references