Matik.Org

Moon Pointer Part Five - Putting it All Together

OK, after creating the mathy mooncalc.py library in the previous chapter, let’s put it all together and start pointing. I assume you have read about the motivation here, if not, go back there first.

let’s assume we have uploaded mooncalc.py to the esp32 board with ampy. Let’s now look at the actual moon pointer code, specifically moontracker.py, which we upload upload via

ampy -p /dev/tty.SLAB_USBtoUART put moontracker.py main.py

As a reminder, we also need to upload supporting libraries:

ampy -p /dev/tty.SLAB_USBtoUART put tm1638.py
ampy -p /dev/tty.SLAB_USBtoUART put micropyGPS.py

Let’s look at some of the code. We first get some imports going. We also do some setup that’s specific for the platform. That’s maybe more complicated than it needs to be, but I’d like to run the code both in micropython as well as my workstation, for testing.

Specifically, I’m setting the UNIX and MICROPYTHON constants, and a TIME_EPOCH_DIFF constants that contains the number of seconds that need to be added to the time returned by time.time() so that the result is in seconds since the Unix epoch, 1/1/1970. ESP32 Micropython starts the epoch at 1/1/2000.

Finally, we setup Debugging (or not), and if debugging a fast forward factor. This allows us to simulate the moon at a higher speed so we can see a bit more “action”. Set it to 100 to have the simulated moon go ‘round’ every 15 minutes or so.

We also set Delta-T for added precision. The formula is off by about 72 seconds in 2020. The NASA site linked in the source describes some of the details and how to adapt Delta-T for other dates.

from math import pi, sin, cos, tan, asin, atan2, acos
import mooncalc
import time

# Find out whether we are running micropython or not also, micropython
# can run on UNIX, too.  so, if UNIX=1 and MICROPYTHON = 0 we're
# running a standard python e.g. on a Mac book.

# We need to know because if UNIX=1, the epoch for time.time starts in
# 1970.  otherwise, time epoch starts in 2000.

UNIX = 1
MICROPYTHON = 0

TIME_EPOCH_DIFF = 0
try:
    import sys
    if sys.implementation[0] == 'micropython':
        MICROPYTHON = 1
        # note: should add other platforms here as well.  The Unix
        # micropython code uses its host time, so its epoch starts at
        # 1970
        if sys.platform == 'esp32':
            UNIX = 0
            TIME_EPOCH_DIFF = 946684815
except:
    pass

# The formulas we use are slightly off, we loose a few seconds per decade.
# in 2020, DELTA_T is about 72.
# For more, see https://eclipse.gsfc.nasa.gov/SEhelp/deltaT.html
DELTA_T = 71.6

# set if we want to debug
DEBUG = False
FAST_FORWARD_FACTOR = 1 # to simulate faster speed, set to value >1, e.g. 100

In the next section, we set up the hardware. We start with the tm1638 module, then set the UART for the GPS module, then optionally the NeoPixels and finally the servos. We load either uasyncio (micropython) or asyncio (regular python).

# to allow to run this on a local computer for testing,
# only deal with hardware on a MCU board:
if MICROPYTHON == 1 and UNIX == 0:
    from machine import UART, Pin, PWM, RTC
    from neopixel import NeoPixel
    import tm1638
    import uasyncio as asyncio
    import utime


    tm = tm1638.TM1638(stb=Pin(4), clk=Pin(16), dio=Pin(17))

    # every 2nd LED on
    tm.leds(0b01010101)

    # dim both LEDs and segments
    tm.brightness(0)
    
    # Two servos - one for azimuth, one for altitude.
    servo_az = PWM(Pin(5), freq=50, duty=77)
    servo_alt = PWM(Pin(22), freq=50, duty=77)


    # Create a GPS module instance.
    from micropyGPS import MicropyGPS
    uart = UART(1, baudrate=9600)
    uart.init(rx=21, tx=18)
    mygps = MicropyGPS(location_formatting='dd')

	if 0: # optional
        npPin = Pin(23, Pin.OUT)
        np = NeoPixel(npPin,1)
else:
    import asyncio

We have some helper function to set up servos and print/date time as string on the TM1638 module. Note that the specific magic values for ALT and AZ servos differ.

# return PWM value to set Azimuth servo to specified angle.
# Empirically, roughly calibrated my specific servos
# Tower PRO SG90
def angle_to_duty(angle, min_val, max_mal):
    if angle < 0 or angle > 180:
        print("illegal angle: ", angle)
        return 0
    return min_val + (180-angle)/180 * (max_val-min_val)


def az_angle_to_duty(angle):
    return angle_to_duty(angle, 32, 143)


# Funny how this is the same model servo but needs quite different calibration.
def alt_angle_to_duty(angle):
    return angle_to_duty(angle, 32, 121)


# helper functions to print date and time in 4 chars each (for
# blinkenlights)
def mydate():
  lt = utime.localtime()
  return int("{:02d}{:02d}".format(lt[1],lt[2]))


def mytime():
  lt = utime.localtime()
  return int("{:02d}{:02d}".format(lt[3],lt[4]))

Next comes the main tracker loop. For simplicity, I’m leaving out some confusing details you can find in the github file. For the most part, we are running an infinite loop. Each iteration, we get the time, get the moon position, translate the native az/alt values into some derivative az2/alt2 values that our servos can handle, set the servos and update the blinkenlights.

The blinkenlights are straightforward, we just alternate a display of az/alt, the current lat/long position (from GPS) and date/time, and scroll a led indicator along the row of 8 LEDs.

The az/alt to az2/alt2 mapping is somewhat tricky - I hope the comments speak for themselves, but please ask in the comments if this is not clear.

# This is the main loop. Every second,
# - compute moon position
# - set servos
# - optionally adjust LED on pointer if we have one
# - update display
# - in debug mode, also send coordinates to console
# - in debug mode, can also fast forward, make moon start at current
#   time but then move it forward X seconds every second.
latitude = 0 # these will be changed by GPS periodically
longitude = 0

async def tracker(delay_ms):
    t = time.time()
    while True:
        pos = mooncalc.get_moon_position(t + DELTA_T + TIME_EPOCH_DIFF,
                                         latitude, longitude) 

        az = int(round(pos.azimuth * 180 /pi + 180))
        alt = int(round(pos.altitude * 180 /pi))
        # since our servos can only rotate 180 degrees, we need to fiddle
        # with the angles to get correct servo positions:

        # we divide the Sphere in 4 quarter spheres. First, we cut the
        # sphere in upper and lower hemishphere. THe moon is below the
        # horizon in the lower hemissphere, so let's only worry about
        # the upper hemisphere for now:
        if alt >0: # Upper hemisphere
            # we assume that the device is oriented so that the servo
            # 0 position points north, and angle increases
            # clockwise. This means, that as long as 0<=az<180, we
            # need to do nothing:
            if az <180:
                az2 = az
                alt2 = alt
            else:
                # otherwise, we'll instead point at az - 180, and choose
                # 180 - alt (0 alt becomes 180 alt, 90 stays same)
                az2 = az - 180
                alt2 = 180 - alt
        else: # lower hemisphere
            # OK, here is a dirty trick to point to the lower
            # hemisphere as well, we use the other end of the pointer strut
			# of the pointer to 'point' to the moon. I'll add a 
			# light on both ends of the strut to make that visually clear.

            if az < 180:
                # az stays same, but for alt: -1 -> 179, -90 -> 90)
                az2 = az
                alt2 = alt + 180
            else:
                # az: 180 -> 0, 360 -> 180)
                # alt: -1 -> +1, -90 ->90
                az2 = az - 180
                alt2 = - alt

        if UNIX != 1:
            # Blinkenlights
            tm.leds(1<<(int(t)%8)) # just make leds scroll every second.

            # for 4 seconds show AZ/ALT
            x = az
            y = alt
            if int(t)%8 < 4:
                pass
            elif int(t)%8 < 6:
                # show position for 2 seconds
                x = int(latitude)
                y = int(longitude)
            else:
                # show date/time for 2 seconds
                x=mydate()
                y=mytime()
            tm_str="{:=4d}{:=4d}".format(x,y)
            tm.show(tm_str)

            # update servos
	        servo_az.duty(az_angle_to_duty(az2))
            servo_alt.duty(alt_angle_to_duty(alt2))

        await asyncio.sleep(delay_ms/1000.0)
        if DEBUG:
            t = t + delay_ms/1000.0 * FAST_FORWARD_FACTOR
        else:
            t = time.time()

We hadn’t talked about where latitude/longitude came from. We basically use two global variables for that that the main loop reads, and this other asynchronous thread writes, every so often. In addition to lat/long, we also take the time from GPS and write it back into the real time clock on board the esp32 module.

Note that we’re using the micropyGPS module. It’s essentially just a parser for the data the GPS module is spitting out. It works by passing in this data character by character, then maintaining the GPS state. At any time, we can call the GPS class and get time, lat/long and so forth. I have only this one GPS module, but the library claims it supports all NMEA GPS modules, which are plentiful and cheap on aliexpress, amazon etc.

async def getGPSFix(delay_sec):
    global latitude, longitude
    while True:
        if uart.any():
            while uart.any():
                r = uart.read()
                for c in r: mygps.update(chr(c))
            latitude = mygps.latitude[0]
            if mygps.latitude[1] == 'S':
                latitude = -latitude
            longitude = mygps.longitude[0]
            if mygps.longitude[1] == 'W':
                longitude = -longitude
            d=mygps.date
            t=mygps.timestamp
            # documentation is lying, parameters to pass are
            # Y,M,D,weekday,H,m,s,subsec
            # rather than
            # Y, M, D, H,m,s, weekday,subsec
            timetuple=(2000+d[2],d[1],d[0],0,t[0],t[1],int(t[2]),0)
            RTC().datetime(timetuple)

            if DEBUG:
                print("set lat/long to {} - {}, time is {}, was {}".format(latitude, longitude, timetuple, RTC().datetime()))
        await asyncio.sleep(delay_sec)

Finally, let’s start everything up:

loop = asyncio.get_event_loop()

loop.create_task(tracker(1000))
if MICROPYTHON == 1 and UNIX == 0:
    loop.create_task(getGPSFix(30))

loop.run_forever()
loop.close()

Et voila, there’s the moon for you. The moon pointer will start with the wrong direction and time until up to 30 seconds after the GPS gets a fix. After that, it will just chug along until you power off. I have been running the pointer for about a week without any trouble. I like that I can place it about anywhere and it will just work, without requiring network access or other user input.

Animation of Blinkenlights

I’d be happy for any suggestions or comments below.

Epilogue

It’s been a few weeks and the moon pointer has worked more or less flawlessly, so I declare it a success :)

I’ve added a neopixels at both ends of the pointer. I lit up the moon end yellow and realized I can use the other end as earth, so I lit it blue, and covered the LEDs with ping-pong balls (so imagine yourself sitting on top of the blue ball). The effect is pretty good:

Moonpointer during the day Moonpointer at night

The weak points are the servo mounts, they failed a couple of times, especially with the device sitting in >35 degree celsius weather in the sun. I should investigate more sturdy mounts, like upgrading from hot glue to zip ties :)

Written on July 30, 2020