We'll be using CircuitPython for this project. Are you new to using CircuitPython? No worries, there is a full getting started guide here.

Adafruit suggests using the Mu editor to edit your code and have an interactive REPL in CircuitPython. You can learn about Mu and its installation in this tutorial.

In addition, the LSM303 module is required. We have a great guide on that. Make sure you have the latest version of this module.

A Guided Tour

The warm_up function is used when starting up to make multiple reads from the magnetometer. Experience showed that the initial reads were unreliable; the magnetometer needs to warm up it would seem. The NeoPixels are set to blue during this phase.

def warm_up():
    fill(BLUE)
    for _ in range(100):
        _, _, _ = compass.magnetic
        time.sleep(0.010)

Compass Calibration

The compass needs to be calibrated before use. This adapts it to the variation in the chip as well as the local magnetic environment.  Simply spin the compass when the NeoPixels are green. If the compass acts erratically, pressing the left button (A) will do an additional calibration phase, which will hopefully improve the calibration. Be sure to spin it a couple full rotations for the widest range of values.

There are a few ways to run a calibration. If the compass has never been calibrated (the raw_mins array contains all 1000.0), it will be calibrated. Additionally, it will be done if the A/left button is pressed when the power is turned on or reset is pressed. In both these cases, it is assumed that you have a USB serial connection. When calibration completes, two lines are output, similar to:

raw_mins = [-2.45455, 2.81818]
raw_maxes = [-1.72727, 3.54545]

Copy these into the code, replacing the two that look the same (other than the values), and load onto the Circuit Playground Express. Subsequently powering on, resetting, or restarting will then skip calibration.

Calibration works by spending 10 seconds making readings, and keeping track of the lowest and highest values along the X and Y axes. At the end, the origin offset is calculated by finding the center point of the readings (averaging the minimum and maximum values). Now we have the offset for readings as well as the expected value ranges.

def calibrate(do_the_readings):
    values = [0.0, 0.0]
    start_time = time.monotonic()

    fill(GREEN)

    # Update the high and low extremes
    if do_the_readings:
        while time.monotonic() - start_time < 10.0:
            values[0], values[1], _ = compass.magnetic
            values[1] *= -1                           # accel is upside down, so y is reversed
            if values[0] != 0.0 and values[1] != 0.0: # ignore the random 0 values
                for i in range(2):
                    if values[i] < raw_mins[i]:
                        raw_mins[i] = values[i]
                    if values[i] > raw_maxes[i]:
                        raw_maxes[i] = values[i]

    # Recompute the correction and the correct mins/maxes
    for i in range(2):
        corrections[i] = (raw_maxes[i] + raw_mins[i]) / 2
        mins[i] = raw_mins[i] - corrections[i]
        maxes[i] = raw_maxes[i] - corrections[i]

    fill(BLACK)

The main loop runs about 20 times per second. It starts by checking for the left button being pushed. If it is, more calibration is done. Next the magnetometer values are read for X and Y. 

    if not calibrate_button.value:
        calibrate(True)

    x, y, _ = compass.magnetic
    y = y * -1

Notice that the Y value is negated since the breakout is mounted upside down, flipping the Y axis.

Occasionally there will be a problem and the read values will both be zero. If so the reading is ignored.

    if x != 0.0 and y != 0.0:

Assuming the values are good, they are normalized. This is a simple mapping of the value from the range that resulted from calibration, to the range from -100 to +100.

def normalize(value, in_min, in_max):
    mapped = (value - in_min) * 200 / (in_max - in_min) + -100
    return max(min(mapped, 100), -100)

Once we have normalized values the atan2 function is used to compute the angle of the vector made by those values. The angle will be in radians (ranging from -pi to +pi). That gets converted to degrees by multiplying by 180/pi. Now we have a value in degrees that ranges from -180 to +180. That gets 180 added to it to give a value from 0 to 360. This is then divided by 30 to give the number of a wedge which corresponds to 1 of 10 NeoPixel positions. Because the wedge at the top is defined by the angles at +/-15 degrees, 15 is added (and the result clipped to 360 by using the modulus operator %) before the division.

        normalized_x = normalize(x - corrections[0], mins[0], maxes[0])
        normalized_y = normalize(y - corrections[1], mins[1], maxes[1])

        compass_heading = int(math.atan2(normalized_y, normalized_x) * 180.0 / math.pi)
        # compass_heading is between -180 and +180 since atan2 returns -pi to +pi
        # this translates it to be between 0 and 360
        compass_heading += 180

        direction_index = ((compass_heading + 15) % 360) // 30

The final step is to update the NeoPixels. The led_patterns array that is defined at the top of the code is used to specify which pixels correspond to each wedge. Notice that two of the wedges specify two pixels, while the others use only one. This is because the Circuit Playgrounds only have 10 NeoPixels; the top and bottom positions are used for the USB and battery connectors. To work around this, the two adjacent NeoPixels are used.

        pixels.fill(BLACK)
        for l in led_patterns[direction_index]:
            pixels[l] = RED
        pixels.show()

The complete code is below:

# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
Circuit Playground Express Compass

Adafruit invests time and resources providing this open source code.
Please support Adafruit and open source hardware by purchasing
products from Adafruit!

Written by Dave Astels for Adafruit Industries
Copyright (c) 2018 Adafruit Industries
Licensed under the MIT license.

All text above must be included in any redistribution.
"""

import time
import math
import sys
import board
import busio
import adafruit_lsm303
import neopixel
import digitalio

BLACK = 0x000000
RED = 0xFF0000
GREEN = 0x00FF00
BLUE = 0x0000FF

i2c = busio.I2C(board.SCL, board.SDA)
compass = adafruit_lsm303.LSM303(i2c)
compass.mag_rate = adafruit_lsm303.MAGRATE_30
#compass.mag_gain = adafruit_lsm303.MAGGAIN_8_1

calibrate_button = digitalio.DigitalInOut(board.BUTTON_A)
calibrate_button.direction = digitalio.Direction.INPUT
calibrate_button.pull = digitalio.Pull.UP


#-------------------------------------------------------------------------------
# Replace these two lines with the results of calibration
#-------------------------------------------------------------------------------
raw_mins = [1000.0, 1000.0]
raw_maxes = [-1000.0, -1000.0]
#-------------------------------------------------------------------------------


mins = [0.0, 0.0]
maxes = [0.0, 0.0]
corrections = [0.0, 0.0]

pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=.2, auto_write=False)

led_patterns =  [[4, 5], [5], [6], [7], [8], [9], [9, 0], [0], [1], [2], [3], [4]]


def fill(colour):
    pixels.fill(colour)
    pixels.show()


def warm_up():
    fill(BLUE)
    for _ in range(100):
        _, _, _ = compass.magnetic
        time.sleep(0.010)


def calibrate(do_the_readings):
    values = [0.0, 0.0]
    start_time = time.monotonic()

    fill(GREEN)

    # Update the high and low extremes
    if do_the_readings:
        while time.monotonic() - start_time < 10.0:
            values[0], values[1], _ = compass.magnetic
            values[1] *= -1                           # accel is upside down, so y is reversed
            if values[0] != 0.0 and values[1] != 0.0: # ignore the random 0 values
                for i in range(2):
                    if values[i] < raw_mins[i]:
                        raw_mins[i] = values[i]
                    if values[i] > raw_maxes[i]:
                        raw_maxes[i] = values[i]
#            time.sleep(0.005)

    # Recompute the correction and the correct mins/maxes
    for i in range(2):
        corrections[i] = (raw_maxes[i] + raw_mins[i]) / 2
        mins[i] = raw_mins[i] - corrections[i]
        maxes[i] = raw_maxes[i] - corrections[i]

    fill(BLACK)


def normalize(value, in_min, in_max):
    mapped = (value - in_min) * 200 / (in_max - in_min) + -100
    return max(min(mapped, 100), -100)



# Setup

warm_up()

if not calibrate_button.value or (raw_mins[0] == 1000.0 and raw_mins[1] == 1000.0):
    print("Compass calibration")
    raw_mins[0] = 1000.0
    raw_mins[1] = 1000.0
    raw_maxes[0] = -1000.0
    raw_maxes[1] = -1000.0
    calibrate(True)


    print("Calibration results")
    print("Update the corresponding lines near the top of the code\n")
    print("raw_mins = [{0}, {1}]".format(raw_mins[0], raw_mins[1]))
    print("raw_maxes = [{0}, {1}]".format(raw_maxes[0], raw_maxes[1]))
    sys.exit()
else:
    calibrate(False)


while True:
    if not calibrate_button.value:
        calibrate(True)

    x, y, _ = compass.magnetic
    y = y * -1

    if x != 0.0 and y != 0.0:
        normalized_x = normalize(x - corrections[0], mins[0], maxes[0])
        normalized_y = normalize(y - corrections[1], mins[1], maxes[1])

        compass_heading = int(math.atan2(normalized_y, normalized_x) * 180.0 / math.pi)
        # compass_heading is between -180 and +180 since atan2 returns -pi to +pi
        # this translates it to be between 0 and 360
        compass_heading += 180

        direction_index = ((compass_heading + 15) % 360) // 30

        pixels.fill(BLACK)
        for l in led_patterns[direction_index]:
            pixels[l] = RED
        pixels.show()
        time.sleep(0.050)

This guide was first published on Oct 25, 2018. It was last updated on Sep 04, 2018.

This page (CircuitPython) was last updated on May 31, 2023.

Text editor powered by tinymce.