├── .gitignore ├── img ├── before-after.jpg └── tracker-schematic.png ├── LICENSE.txt ├── calibration ├── level.py └── measure.py ├── README.md └── controller └── motor_control.ino /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | -------------------------------------------------------------------------------- /img/before-after.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/partofthething/startracker/HEAD/img/before-after.jpg -------------------------------------------------------------------------------- /img/tracker-schematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/partofthething/startracker/HEAD/img/tracker-schematic.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nicholas W. Touran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /calibration/level.py: -------------------------------------------------------------------------------- 1 | """ 2 | Digital Level Reader. 3 | 4 | Reads DXL360 digital level measurements coming in over the serial port. 5 | 6 | Data comes in via UART, converted to USB with a FT232H or equiv. 7 | Format is X+0000Y+0000 8 | 9 | Original Purpose: Calibrate/verify star-tracking barn-door camera mount. 10 | By: Nick Touran 11 | """ 12 | import serial 13 | import re 14 | import time 15 | 16 | 17 | DEV = '/dev/ttyUSB0' 18 | BAUD = 9600 19 | MSG_LENGTH = 12 20 | 21 | 22 | def read(): 23 | with serial.Serial(DEV, BAUD, timeout=1) as port: 24 | # sync up when you find an 'X' character 25 | char = b'' 26 | while char != b'X': 27 | char = port.read() 28 | port.read(MSG_LENGTH - 1) # toss one datum since X was consumed 29 | start = time.time() 30 | seconds = 0.0 31 | # now read continuously and process into floats 32 | while True: 33 | datum = port.read(MSG_LENGTH).decode() 34 | match = re.search('X([+-]\d\d\d\d)Y([+-]\d\d\d\d)', datum) 35 | if not match: 36 | raise ValueError('Level did not provide valid data. Check connection.') 37 | x, y = match.groups() 38 | x = int(x) * 0.01 39 | y = int(y) * 0.01 40 | seconds = time.time() - start 41 | print('{:<10.1f} {: 6.2f} {: 6.2f}'.format(seconds, x, y)) 42 | yield (seconds, x, y) 43 | 44 | if __name__ == '__main__': 45 | read() 46 | 47 | -------------------------------------------------------------------------------- /calibration/measure.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read angle vs. time from level and plot and analyze. 3 | """ 4 | 5 | import math 6 | 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | 10 | import level 11 | 12 | 13 | 14 | DATA_FILE = 'data2.txt' 15 | 16 | def measure(duration_in_seconds=20): 17 | t, x, y = [], [], [] 18 | 19 | 20 | for seconds, xi, yi in level.read(): 21 | t.append(seconds) 22 | x.append(xi) 23 | y.append(yi) 24 | if seconds > duration_in_seconds: 25 | break 26 | 27 | write(t, x, y) 28 | 29 | plt.plot(t, x, '.') 30 | plt.xlabel('Time (s)') 31 | plt.ylabel('Angle (degrees)') 32 | plt.title('Level data') 33 | plt.grid(alpha=0.3) 34 | plt.show() 35 | 36 | def write(t, x, y): 37 | with open(DATA_FILE, 'w') as f: 38 | for ti, xi, yi in zip(t, x, y): 39 | f.write('{:<10.1f} {: 6.2f} {: 6.2f}\n'.format(ti, xi, yi)) 40 | 41 | 42 | def read(): 43 | with open(DATA_FILE) as f: 44 | data = f.readlines() 45 | t, x, y = [], [], [] 46 | for line in data: 47 | ti, xi, yi = (float(i) for i in line.split()) 48 | t.append(ti) 49 | x.append(xi) 50 | y.append(yi) 51 | return t, x, y 52 | 53 | 54 | def analyze(t, x, y): 55 | slope, intercept = np.polyfit(t, x, 1) 56 | result = slope * math.pi / 180.0 57 | fit = np.array(t) * slope + intercept 58 | plt.plot(t, x, '.', label='Data') 59 | plt.plot(t, fit, '-', label='Fit') 60 | 61 | plt.xlabel('Time (s)') 62 | plt.ylabel('Angle (degrees)') 63 | plt.title('Level data') 64 | plt.grid(alpha=0.3) 65 | plt.legend() 66 | ax = plt.gca() 67 | ax.text(0.1, 0.15, r'$\frac{{d\Theta}}{{dt}}$ = {:.3e} rad/s'.format(result), 68 | transform=ax.transAxes, 69 | color='green', fontsize=14) 70 | plt.show() 71 | 72 | if __name__ == '__main__': 73 | t, x, y = read() 74 | analyze(t, x, y) 75 | # measure(10 * 60) 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Startracker 2 | **Startracker** is the control and calibration software that goes with a simple-to-build DIY barn-door star-tracking camera mount for astrophotography (including eclipse photography). The build prefers simple construction and corrects various complexities (the tangent error, construction imperfections) in software. 3 | 4 | The details of building the startracker and derivations behind the math are available here: https://partofthething.com/thoughts/?p=1327 5 | 6 | The Earth rotates once per day. If you want to take long-exposure photos of stars, or if you want to take hundreds of shorter exposures of the same area (for stacking), you need to keep your camera pointed at the same stars as the world it sits on rotates. This does that for you. 7 | 8 | ![Images with the tracker off and on](img/before-after.jpg) 9 | 10 | ![The scematic](img/tracker-schematic.png) 11 | 12 | ## Using startracker 13 | There are two parts of this project: the *controller* code and the *calibration* code. The controller is intended to be loaded onto a ESP8266 microcontroller via the Arduino IDE. You should adjust some of the constants at the top to reflect the idiosyncracies of your particular device (especially the precise length measurement). 14 | 15 | ### Connections 16 | The stepper motor controller should be connected to 4 pins (D1, D2, D3, D4 by default). A control button should be connected to another GPIO as well (D7 by default), and should connect to ground when it's pressed. 17 | 18 | ### Operation 19 | * When the ESP8266 is turned on, it begins turning the motor and assumes it started at fully retracted position. It accelerates accordingly to correct the tangent error. 20 | * If you press the button, the startracker fast-rewinds to its initial position and stops 21 | * If you press the button again, the startracker starts turning again 22 | * If you hold the button while turning on the startracker, it will start fast-rewinding with reckless abandon until you let go of the button. Use this to manually rewind the threaded rod. 23 | 24 | ## Calibrations 25 | 26 | The *calibration* code is (totally optional!) Python code that you can run on your computer in conjunction with a digital level to make very precise calibration corrections to the rate that your barn door startracker opens. To use, just run `measure.py` once in collect mode (you'll have to adjust the code at the bottom) and then again in analyze mode to get a least-squares analysis. See [the blog](https://partofthething.com/thoughts/?p=1327) for details. You can adjust the rate in the motor controller according to how you do on the calibration if you want, but I was able to get within 0.5% of the desired rate without calibrating. Nice. 27 | 28 | ## Contributing 29 | This project was done for fun by Nick Touran and is accordingly very small. If you'd like to contribute, feel free to just make a pull request. 30 | 31 | 32 | -------------------------------------------------------------------------------- /controller/motor_control.ino: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * Skytracker by Nick Touran for ESP8266 and Stepper Motors 4 | * 5 | * partofthething.com 6 | * 7 | * This accelerates the motor to correct the tangent error. It can rewind too! 8 | * 9 | * Motors are 28BYJ-48 5V + ULN2003 Driver Board from Amazon 10 | * Hook up power and ground and then hook inputs 1-4 up to GPIO pins. 11 | * 12 | * See Also: http://www.raspberrypi-spy.co.uk/2012/07/stepper-motor-control-in-python/ 13 | * This motor has a 1:64 gear reduction ratio and a stride angle of 5.625 deg (1/64th of a circle). 14 | * 15 | * So it takes 64*64 = 4096 single steps for one full rotation, or 2048 double-steps. 16 | * with 3 ms timing, double-stepping can do a full rotation in 2048*0.003 = 6.144 seconds 17 | * so that's a whopping 1/6.144 * 60 = 9.75 RPM. But it has way more torque than I expected. 18 | * 19 | * Can get 2ms timing going with double stepping on ESP8266. Pretty fast! 20 | * Should power it off of external 5V. 21 | * 22 | * Shaft diameter is 5mm with a 3mm inner key thing. Mounting holes are 35mm apart. 23 | * 24 | */ 25 | 26 | 27 | #define NUM_PINS 4 28 | #define NUM_STEPS 8 29 | #define RADS_PER_SEC 7.292115e-05 30 | #define LENGTH_CM 28.884 // fill in with precise measured value 31 | // For theta zero, I used relative measurement between two boards w/ level. 32 | // Got 0.72 degrees, which is 0.012566 radians 33 | #define THETA0 0.012566 // fill in with angle at fully closed position (radians) 34 | #define ROTATIONS_PER_CM 7.8740157 // 1/4-20 thread 35 | #define DOUBLESTEPS_PER_ROTATION 2048.0 36 | #define CYCLES_PER_SECOND 80000000 37 | 38 | //modes 39 | #define NORMAL 0 40 | #define REWINDING 1 41 | #define STOPPED 2 42 | 43 | 44 | int allPins[NUM_PINS] = {D1, D2, D3, D4}; 45 | int MODE_PIN = D7; 46 | 47 | // from manufacturers datasheet 48 | int STEPPER_SEQUENCE[NUM_STEPS][NUM_PINS] = {{1,0,0,1}, 49 | {1,0,0,0}, 50 | {1,1,0,0}, 51 | {0,1,0,0}, 52 | {0,1,1,0}, 53 | {0,0,1,0}, 54 | {0,0,1,1}, 55 | {0,0,0,1}}; 56 | 57 | int step_delta; 58 | int step_num = 0; 59 | double total_seconds = 0.0; 60 | long totalsteps = 0; 61 | double step_interval_s=0.001; 62 | int *current_step; 63 | volatile unsigned long next; // next time to trigger callback 64 | volatile unsigned long now; // volatile keyword required when things change in callbacks 65 | volatile unsigned long last_toggle; // for debounce 66 | volatile short current_mode=NORMAL; 67 | bool autostop=true; // hack for allowing manual rewind at boot 68 | 69 | float ypt(float ts) { 70 | // bolt insertion rate in cm/s: y'(t) 71 | // Note, if you run this for ~359 minutes, it goes to infinity!! 72 | return LENGTH_CM * RADS_PER_SEC/pow(cos(THETA0 + RADS_PER_SEC * ts),2); 73 | } 74 | 75 | void inline step_motor(void) { 76 | /* This is the callback function that gets called when the timer 77 | * expires. It moves the motor, updates lists, recomputes 78 | * the step interval based on the current tangent error, 79 | * and sets a new timer. 80 | */ 81 | switch(current_mode) { 82 | case NORMAL: 83 | step_interval_s = 1.0/(ROTATIONS_PER_CM * ypt(total_seconds)* 2 * DOUBLESTEPS_PER_ROTATION); 84 | step_delta = 1; // single steps while filming for smoothest operation and highest torque 85 | step_num %= NUM_STEPS; 86 | break; 87 | case REWINDING: 88 | // fast rewind 89 | step_interval_s = 0.0025; // can often get 2ms but gets stuck sometimes. 90 | step_delta = -2; // double steps going backwards for speed. 91 | if (step_num<0) { 92 | step_num+=NUM_STEPS; // modulus works here in Python it goes negative in C. 93 | } 94 | break; 95 | case STOPPED: 96 | step_interval_s = 0.2; // wait a bit to conserve power. 97 | break; 98 | } 99 | 100 | if (current_mode!=STOPPED) { 101 | total_seconds += step_interval_s; // required for tangent error 102 | current_step = STEPPER_SEQUENCE[step_num]; 103 | do_step(current_step); 104 | step_num += step_delta; // double-steppin' 105 | totalsteps += step_delta; 106 | } 107 | 108 | // Serial.println(totalsteps); 109 | // Before setting the next timer, subtract out however many 110 | // clock cycles were burned doing all the work above. 111 | now = ESP.getCycleCount(); 112 | next = now + step_interval_s * CYCLES_PER_SECOND - (now-next); // will auto-rollover. 113 | timer0_write(next); // see you next time! 114 | } 115 | 116 | void do_step(int *current_step) { 117 | /* apply a single step of the stepper motor on its pins. */ 118 | for (int i=0;i