Sunday, November 19, 2017

Self-balancing robot version 3 update!


It works! Now it balances on hard or soft floors for more than 30 minutes!



What changed?

Firstly, 2xAA rechargeable Eneloop batteries are insufficient to power those FS90R motors. The specification of the motor allows up to 6V. Two cells provide only around 2.7V total. I switch to 4xAA Eneloop batteries, providing around 5.4V. The motors were much much faster, more than twice as fast. The immediate consequence of this is that the PID parameters need to be re-tuned and scaled down to 10-20% of the values for 2xAA batteries.

Secondly, I turned up the builtin digital low pass filter in the MPU-9250 aggressively. Now it is set to 5 for both accelerometer and gyroscope. That translates to a sensor delay of 17ms and the 3dB bandwidth of about 10Hz. That is probably averaging around 17 samples at 1kHz sample rate. And that did the trick!

Here's a graph of motor power, gyroscope angular velocity and tilt angle.


The filtered angular velocity seldom exceeds 0.3 degrees per second. The filtered tilt angle seldom exceeds 1 degree. The motor power is mostly less than 10% of maximum power.

I've updated the InvenSense MPU-9255/MPU-9250/MPU-6500 module. You can now specify the low pass filter level for the accelerometer and gyroscope in the call to InvMPU.reset_mpu(). Copy this new version into your custom.ts.

The only changes from the version 1 code are the PID tuning parameters and the call InvMPU.reset_mpu(5, 5).

See the previous post on the parts for building version 3.



Thursday, November 16, 2017

Self-balancing robot version 3


Version 1 was made out of Lego. It keeps breaking into pieces, the motors were not secured and it also contained the unnecessary Kitronic servo:lite board. So, I rebuilt it.

Parts:

The code for this version is identical to the code for version 1 except the tweek for a different orientation of the MPU-9255. With 2x AA batteries, identical PID parameters will work. With 4x AA batteries, the parameters have to be retuned.

But, alas, it still doesn't balance well on smooth hard floors!

It works fine on carpet or cushion even though it is only half the height of the version 1 robot. It just won't work on wood or marble floors!

After much tuning, I decided to record the motor response. I added two more wheels and turned the bot into a "car". Then I recorded the horizontal acceleration using the accelerometer in response to various motor control like a 1 second 66% power pulse. Here is a typical result with 2x AA batteries:


Why is the acceleration oscillating around zero? The amplitudes of the oscillations are much worse with 4x AA batteries.

Oh, the motor controller in the servo is a closed loop controller!

The closed-loop controller inside the FS90R is probably a variant of the standard PID controller. The oscillations are caused by the controller trying to match the output voltage to the pulse duration.

The accelerometer and gyroscope are picking up physical feedback from the controller's actions. On soft floor like carpet, the feedback is dampened physically. On hard floors, the sensors pick up random spikes that throws the PID algorithm off.

On top of that, the accelerometer only picks up the increase in motor speed after about 60ms!

In short, don't build a self-balancing robot using servo motors. Just use DC motors.

Update: I've fixed the problem and got it to work on hard floors too!


Monday, November 13, 2017

Trig module for micro:bit

I've updated the Trig module for micro:bit.

Now includes atan2(), sin(), cos(), rotate2d() and test code for the trigonometry functions.

Just cut and paste these into your custom.ts in MakeCode and you can skip the test code (control.assert).


/**
 * Public domain. Use at your own risk!
 * Trigonometry functions
 */
//% weight=90 color=#00A040
namespace Trig {
    const atan_table: number[] = [
        0, 1144, 2289, 3435, 4583, 5734, 6889, 8047, 9211, 10380, // 0
        11556, 12739, 13931, 15131, 16340, 17561, 18793, 20037, 21294, 22566, // 10
        23854, 25157, 26479, 27819, 29179, 30560, 31965, 33393, 34847, 36328, // 20
        37838, 39379, 40952, 42560, 44205, 45889, 47615, 49385, 51203, 53071, // 30
        54992, 56970, 59009, 61114, 63288, 65536, 67865, 70279, 72786, 75391, // 40
        78103, 80931, 83883, 86970, 90203, 93596, 97162, 100917, 104880, 109071, // 50
        113512, 118231, 123256, 128622, 134369, 140543, 147197, 154394, 162208, 170728, // 60
        180059, 190331, 201700, 214359, 228552, 244584, 262851, 283868, 308323, 337154, // 70
        371674, 413779, 466313, 533748, 623534, 749080, 937209, 1250502, 1876706, 3754555, // 80
        37549324, // 89.9 approx 90
    ];

    /**
     * Returns the inverse tangent of y/x in degrees * 100.
     * @param y Number between -32768 and 32768, eg: 2000
     * @param x Number between -32768 and 32768, eg: -1000
     */
    //% block
    //% weight=100
    export function atan2(y: number, x: number): number {
        // returns degrees * 100
        control.assert(y <= 32768 && y >= -32768, "atan2: y must be between -32768 and 32768: " + y)
        control.assert(x <= 32768 && x >= -32768, "atan2: x must be between -32768 and 32768: " + x)
        if (x == 0) {
            if (y == 0) {
                return 0;
            } else if (y > 0) {
                return 9000;
            } else {
                return -9000;
            }
        }
        let ratio = (y << 16) / x;
        let sign = 1;
        if (ratio < 0) {
            sign = -1;
            ratio = - ratio;
        }
        for (let i = 1; i < atan_table.length; i++) {
            if (ratio < atan_table[i]) {
                let d = atan_table[i] - atan_table[i - 1];
                let d2 = ratio - atan_table[i - 1];
                let d3 = d2 > 21474836 ? d2 * 10 / d * 10 : d2 * 100 / d;
                if (x < 0) {
                    return sign * ((i - 1) * 100 + d3 - 18000);
                } else {
                    return sign * ((i - 1) * 100 + d3);
                }
            }
        }
        return sign * 9000;
    }
    control.assert(atan2(0, 0) == 0, "bad atan2(0, 0) = " + atan2(0, 0));
    control.assert(atan2(1, 0) == 9000, "bad atan2(1, 0) = " + atan2(1, 0));
    control.assert(atan2(-1, 0) == -9000, "bad atan2(-1, 0) = " + atan2(-1, 0));
    control.assert(atan2(1, 1) == 4500, "bad atan2(1, 1) = " + atan2(1, 1));
    control.assert(atan2(-1, 1) == -4500, "bad atan2(-1, 1) = " + atan2(-1, 1));
    control.assert(atan2(1, -1) == 13500, "bad atan2(1, -1) = " + atan2(1, -1));
    control.assert(atan2(-1, -1) == -13500, "bad atan2(-1, -1) = " + atan2(1, 1));
    control.assert(atan2(1, 2) == 2656, "bad atan2(1, 2) = " + atan2(1, 2));
    control.assert(atan2(-1, 2) == -2656, "bad atan2(-1, 2) = " + atan2(-1, 2));
    control.assert(atan2(1, -2) == 15344, "bad atan2(1, -2) = " + atan2(1, -2));
    control.assert(atan2(-1, -2) == -15344, "bad atan2(-1, -2) = " + atan2(1, -2));
    control.assert(atan2(572, 1) == 8990, "bad atan2(572, 1) = " + atan2(572, 1));

    const sin_table: number[] = [
        0, 572, 1144, 1715, 2286, 2856, 3425, 3993, 4560, 5126, // 0
        5690, 6252, 6813, 7371, 7927, 8481, 9032, 9580, 10126, 10668, // 10
        11207, 11743, 12275, 12803, 13328, 13848, 14365, 14876, 15384, 15886, // 20
        16384, 16877, 17364, 17847, 18324, 18795, 19261, 19720, 20174, 20622, // 30
        21063, 21498, 21926, 22348, 22763, 23170, 23571, 23965, 24351, 24730, // 40
        25102, 25466, 25822, 26170, 26510, 26842, 27166, 27482, 27789, 28088, // 50
        28378, 28660, 28932, 29197, 29452, 29698, 29935, 30163, 30382, 30592, // 60
        30792, 30983, 31164, 31336, 31499, 31651, 31795, 31928, 32052, 32166, // 70
        32270, 32365, 32449, 32524, 32588, 32643, 32688, 32723, 32748, 32763, // 80
        32768,
    ];

    function sin_deg(d: number): number {
        if (d >= 0 && d <= 90) {
            return sin_table[d];
        } else if (d > 90 && d <= 180) {
            return sin_table[180 - d];
        } else if (d < 0 && d >= -90) {
            return -sin_table[-d];
        } else {
            return -sin_table[180 + d];
        }
    }
    function cos_deg(angle: number): number {
        if (angle >= 0) {
            return sin_deg(90 - angle);
        } else {
            return sin_deg(90 + angle);
        }
    }
    function sin_small(x: number): number {
        return [0, 57, 114, 172, 229, 286, 343, 400, 458, 515][x];
    }
    function cos_small(x: number): number {
        return [32768, 32768, 32768, 32768, 32767, 32767, 32766, 32766, 32765, 32764][x];
    }

    /**
     * Returns 32768 * sin of the angle.
     * @param angle Degrees * 100, between -18000 and 18000, eg: 9000
     */
    //% block
    //% weight=90
    export function sin(angle: number): number {
        control.assert(angle >= -18000 && angle <= 18000, "angle must be netween -18000 and 18000: " + angle);
        if (angle < 0) { // microbit rounds towards 0
            let z = (-angle + 5) / 10;
            let r = z % 10;
            let d = z / 10;
            return -(sin_deg(d) * cos_small(r) + cos_deg(d) * sin_small(r)) >> 15;
        } else {
            let z = (angle + 5) / 10;
            let r = z % 10;
            let d = z / 10;
            return (sin_deg(d) * cos_small(r) + cos_deg(d) * sin_small(r)) >> 15;
        }
    }

    control.assert(sin(0) == 0, "bad sin(0) = " + sin(0));
    control.assert(sin(3000) == 16384, "bad sin(3000) = " + sin(3000));
    control.assert(sin(6000) == 28378, "bad sin(6000) = " + sin(6000));
    control.assert(sin(9000) == 32768, "bad sin(9000) = " + sin(9000));
    control.assert(sin(12000) == 28378, "bad sin(12000) = " + sin(12000));
    control.assert(sin(15000) == 16384, "bad sin(15000) = " + sin(15000));
    control.assert(sin(18000) == 0, "bad sin(18000) = " + sin(18000));
    control.assert(sin(-3000) == -16384, "bad sin(-3000) = " + sin(-3000));
    control.assert(sin(-6000) == -28378, "bad sin(-6000) = " + sin(-6000));
    control.assert(sin(-9000) == -32768, "bad sin(-9000) = " + sin(-9000));
    control.assert(sin(-12000) == -28378, "bad sin(-12000) = " + sin(-12000));
    control.assert(sin(-15000) == -16384, "bad sin(-15000) = " + sin(-15000));
    control.assert(sin(-18000) == 0, "bad sin(-18000) = " + sin(-18000));
    control.assert(sin(10) == 57, "bad sin(10) = " + sin(10));
    control.assert(sin(20) == 114, "bad sin(20) = " + sin(20));
    control.assert(sin(30) == 172, "bad sin(30) = " + sin(30));
    control.assert(sin(40) == 229, "bad sin(40) = " + sin(40));
    control.assert(sin(50) == 286, "bad sin(50) = " + sin(50));
    control.assert(sin(60) == 343, "bad sin(60) = " + sin(60));
    control.assert(sin(70) == 400, "bad sin(70) = " + sin(70));
    control.assert(sin(80) == 458, "bad sin(80) = " + sin(80));
    control.assert(sin(90) == 515, "bad sin(90) = " + sin(90));
    control.assert(sin(-10) == -57, "bad sin(-10) = " + sin(-10));
    control.assert(sin(-20) == -114, "bad sin(-20) = " + sin(-20));
    control.assert(sin(-30) == -172, "bad sin(-30) = " + sin(-30));
    control.assert(sin(-40) == -229, "bad sin(-40) = " + sin(-40));
    control.assert(sin(-50) == -286, "bad sin(-50) = " + sin(-50));
    control.assert(sin(-60) == -343, "bad sin(-60) = " + sin(-60));
    control.assert(sin(-70) == -400, "bad sin(-70) = " + sin(-70));
    control.assert(sin(-80) == -458, "bad sin(-80) = " + sin(-80));
    control.assert(sin(-90) == -515, "bad sin(-90) = " + sin(-90));
    control.assert(sin(3000) == 16384, "bad sin(3000) = " + sin(3000));
    control.assert(sin(3010) == 16433, "bad sin(3010) = " + sin(3010)); // should really be 16434
    control.assert(sin(3020) == 16482, "bad sin(3020) = " + sin(3020)); // should really be 16483
    control.assert(sin(3030) == 16532, "bad sin(3030) = " + sin(3030));
    control.assert(sin(3040) == 16581, "bad sin(3040) = " + sin(3040)); // should really be 16582
    control.assert(sin(3050) == 16631, "bad sin(3050) = " + sin(3050));
    control.assert(sin(3060) == 16680, "bad sin(3060) = " + sin(3060));
    control.assert(sin(3070) == 16729, "bad sin(3070) = " + sin(3070));
    control.assert(sin(3080) == 16779, "bad sin(3080) = " + sin(3080));
    control.assert(sin(3090) == 16828, "bad sin(3090) = " + sin(3090));
    control.assert(sin(-3000) == -16384, "bad sin(-3000) = " + sin(-3000));
    control.assert(sin(-3010) == -16434, "bad sin(-3010) = " + sin(-3010));
    control.assert(sin(-3020) == -16483, "bad sin(-3020) = " + sin(-3020));
    control.assert(sin(-3030) == -16533, "bad sin(-3030) = " + sin(-3030)); // should really be -16532
    control.assert(sin(-3040) == -16582, "bad sin(-3040) = " + sin(-3040));
    control.assert(sin(-3050) == -16632, "bad sin(-3050) = " + sin(-3050)); // should really be -16631
    control.assert(sin(-3060) == -16681, "bad sin(-3060) = " + sin(-3060)); // should really be -16680
    control.assert(sin(-3070) == -16730, "bad sin(-3070) = " + sin(-3070)); // should really be -16729
    control.assert(sin(-3080) == -16780, "bad sin(-3080) = " + sin(-3080)); // should really be -16779
    control.assert(sin(-3090) == -16829, "bad sin(-3090) = " + sin(-3090)); // should really by -16828

    /**
     * Returns 32768 * cos of the angle.
     * @param angle Degrees * 100, between -18000 and 18000, eg: 9000
     */
    //% block
    //% weight=89
    export function cos(angle: number): number {
        if (angle >= 0) {
            return sin(9000 - angle);
        } else {
            return sin(9000 + angle);
        }
    }
    control.assert(cos(0) == 32768, "bad cos(0) = " + cos(0));
    control.assert(cos(3000) == 28378, "bad cos(000) = " + cos(3000));
    control.assert(cos(6000) == 16384, "bad cos(6000) = " + cos(6000));
    control.assert(cos(9000) == 0, "bad cos(9000) = " + cos(9000));
    control.assert(cos(12000) == -16384, "bad cos(12000) = " + cos(12000));
    control.assert(cos(15000) == -28378, "bad cos(15000) = " + cos(15000));
    control.assert(cos(18000) == -32768, "bad cos(18000) = " + cos(18000));
    control.assert(cos(-3000) == 28378, "bad cos(-3000) = " + cos(-3000));
    control.assert(cos(-6000) == 16384, "bad cos(-6000) = " + cos(-6000));
    control.assert(cos(-9000) == 0, "bad cos(-9000) = " + cos(-9000));
    control.assert(cos(-12000) == -16384, "bad sin(-12000) = " + sin(-12000));
    control.assert(cos(-15000) == -28378, "bad sin(-15000) = " + sin(-15000));
    control.assert(cos(-18000) == -32768, "bad sin(-18000) = " + sin(-18000));

    /**
     * Rotates a vector [x, y] by angle degrees anti-clockwise and updates it in place.
     * @param angle Degrees * 100, between -18000 and 18000, eg: 9000
     * @param v Vector represemted as an array [x, y]
     */
    //% block
    //% weight=80
    export function rotate2d(angle: number, v: number[]) {
        let c = cos(angle);
        let s = sin(angle);
        let v0 = (c * v[0] - s * v[1]) >> 15;
        let v1 = (s * v[0] + c * v[1]) >> 15;
        v[0] = v0;
        v[1] = v1;
    }
    let t: number[] = [20000, 30000];
    rotate2d(9000, t);
    control.assert(t[0] == -30000 && t[1] == 20000, "After rotate 90 wrong: " + t[0] + ", " + t[1]);
    rotate2d(-9000, t);
    control.assert(t[0] == 20000 && t[1] == 30000, "After rotate -90 wrong: " + t[0] + ", " + t[1]);
    rotate2d(4500, t);
    control.assert(t[0] == -7071 && t[1] == 35354, "After rotate 45 wrong: " + t[0] + ", " + t[1]);
    rotate2d(-4500, t);
    control.assert(t[0] == 19998 && t[1] == 29998, "After rotate -45 wrong: " + t[0] + ", " + t[1]);
}

Friday, November 10, 2017

Microbit serial to file script

How do you save thousands of lines from the micro:bit connected via the serial port to a file?

screen? Cut and paste from the terminal?

That gets tiring after a while. Furthermore, the name of the serial device on OS X keeps changing.

Well, here's a handy little script. It looks for the first connected micro:bit and then copies the serial output to stdout. You can tee or pipe it to a file.

Requires pyserial. Only tested on OS X. As usual, use at your own risk.



import serial
from serial.tools.list_ports import comports as list_serial_ports
import sys

def get_microbit_port(ser_id):
    ports = list_serial_ports()
    if ser_id != "": 
        for port in ports:
            if ser_id in port[2]:
                return port[0]
        return None
    for port in ports:
        if "VID:PID=0D28:0204" in port[2]:
            return port[0]
    return None

def main():
    id = ""
    if len(sys.argv) > 1:
        id = sys.argv[1]
    port = get_microbit_port(id)
    if port == None:
        print("micro:bit not connected")
        sys.exit(1)

    try:
        ser = serial.Serial(port, baudrate=115200)
        while True:
            line = ser.readline()
            print line,
            sys.stdout.flush()
    except serial.SerialException as e:
        print "Serial line disconnected"
    except KeyboardInterrupt:
        print "Quit"
    finally:
        try:
            ser.close()
        except:
            pass

main()

Thursday, November 9, 2017

Micro-bit logging module

One more utility package.

When trying to balance the robot, it is hard to know what is going on without logging the numbers from every iteration of the control loop. However, there is no straightforward way to log 6 data points 100 times per seconds for 10 seconds. Bluetooth and radio are too slow and lossy. Attaching a serial line affects the balance of the robot. Try the simple way of adding numbers to an array and it runs out of memory quickly.

Well, there's an undocumented Buffer class that can store an array of bytes. This Logging class is a wrapper around that class. It provides a simple interface to repeatedly log a line consisting of a fixed number of numbers. In the constructor, specify the number of lines to keep and a list of byte sizes, one for each number. Then call add() to log a line of data. If the logger runs out of space, it'll wraparound and throw away the oldest data. Finally, call the sendToSerial() method to write the buffer to the serial line in human readable format. Each call to add() becomes a line of numbers separated by space.

There is enough memory for about 8000 bytes.

Add this to the end of your custom.ts.


/**
 * Public domain. Use at your own risk!
 * Logging functions
 */
namespace Log {
    export class Log {
        private line_count: number;
        private labels: Array;
        private sizes: Array;
        private line_size: number;
        private buf_size: number;
        private buf: Buffer;
        private tail: number;
        private full: boolean;

        /**
         * Creates a logging object
         * @param line_count Number of lines, eg: 1000
         * @param sizes Array of number byte size per line, eg [4, 1, 1, 2]
         */
        constructor(line_count: number, labels: Array, sizes: Array) {
            this.line_count = line_count;
            this.labels = labels;
            this.sizes = sizes;
            control.assert(this.labels.length == this.sizes.length);
            this.line_size = 0;
            for (let i = 0; i < sizes.length; i++) {
                let s = sizes[i];
                control.assert(s == 1 || s == 2 || s == 4);
                this.line_size = this.line_size + s;
            }
            this.buf_size = this.line_size * this.line_count;
            this.buf = pins.createBuffer(this.buf_size);
            this.tail = 0;
            this.full = false;
        }

        /**Adds the list of numbers to the log according to the byte sizes array in the constructor.
         * @param l List of numbers to be added
         */
        add(l: Array) {
            let p = this.tail * this.line_size;
            for (let i = 0; i < this.sizes.length; i++) {
                let s = this.sizes[i];
                let n = l[i];
                switch (s) {
                    case 1:
                        this.buf.setNumber(NumberFormat.Int8LE, p, n);
                        break;
                    case 2:
                        this.buf.setNumber(NumberFormat.Int16LE, p, n);
                        break;
                    case 4:
                        this.buf.setNumber(NumberFormat.Int32LE, p, n);
                        break;
                }
                p = p + s;
            }
            control.assert(p == (this.tail + 1) * this.line_size);
            this.tail = this.tail + 1;
            if (this.tail >= this.line_count) {
                this.tail = 0;
                this.full = true;
            }
        }

        clear() {
            this.tail = 0;
            this.full = false;
        }

        //%
        sendToSerial() {
            let start = 0;
            let count = this.tail;
            if (this.full) {
                count = this.line_count;
                start = this.tail;
            }
            for (let i = 0; i < this.labels.length; i++) {
                serial.writeString(this.labels[i]);
                if (i == this.labels.length - 1) {
                    serial.writeLine("");
                } else {
                    serial.writeString(",");
                }
            }
            for (let i = 0; i < count; i++) {
                this.sendLine((i + start) % this.line_count);
            }
        }

        private sendLine(index: number) {
            let p = index * this.line_size;
            for (let i = 0; i < this.sizes.length; i++) {
                let s = this.sizes[i];
                switch (s) {
                    case 1:
                        serial.writeNumber(this.buf.getNumber(NumberFormat.Int8LE, p));
                        break;
                    case 2:
                        serial.writeNumber(this.buf.getNumber(NumberFormat.Int16LE, p));
                        break;
                    case 4:
                        serial.writeNumber(this.buf.getNumber(NumberFormat.Int32LE, p));
                        break;
                }
                p = p + s;
                if (i == this.sizes.length - 1) {
                    serial.writeLine("");
                } else {
                    serial.writeString(",");
                }
            }
        }
    }
}


Example code:


// Each line is consists of 3 numbers: [4 bytes, 1 byte, 2 bytes]
let l = new Log.Log(1000, ["time", "delta", "x"], [4, 1, 2])

input.onButtonPressed(Button.A, () => {
    serial.writeLine("START");
    l.sendToSerial();
    serial.writeLine("END");
})

basic.showIcon(IconNames.Heart)
let last = input.runningTime();
let x = 0;
while (true) {
    let t = input.runningTime();
    l.add([t, t - last, x]);
    last = t;
    x = x + 1;
    basic.pause(1);
}

Wednesday, November 8, 2017

Code for self-balancing micro:bit robot with DC motors



The DC motors version of the code is similar to the servo version except for the motor control code and the tuning parameters.

This works with version 2 of the self-balancing robot.

See the code for version 1 for more info.


/**
 * Public domain. Use at your own risk!
 * Self-balancing robot controller, controlling a pair of FS90R servos.
 * Requires InvMPU and Trig package in custom.ts.
 * Update read_gyro_angle_rate() and read_accel_tilt_angle() to sensor mounting.
 * Update call to InvMPU.set_gyro_bias() with the bias of your sensor.
 */

// Tuning Parameters
const TARGET_ANGLE = -560; // forward tilt from vertical in degrees * 100
const KE = 2400;
const KD = 24;
const KI = 30;

const motor_bias = 0; // increase to reduce forward power to the right

function read_gyro_angle_rate(): number {
    InvMPU.read_gyro()
    return -InvMPU.gyro_y
}

function read_accel_tilt_angle(): number {
    InvMPU.read_accel()
    return Trig.atan2(-InvMPU.accel_z, InvMPU.accel_x); // degrees * 100
}

function updateAngle(est_angle: number, delta_t_ms: number): number {
    let accel_angle = read_accel_tilt_angle(); // degrees * 100
    let gyro_angle_rate = read_gyro_angle_rate() * 200000 / 32768; // (degrees * 100) per second
    let gyro_angle_change = gyro_angle_rate * delta_t_ms / 1000; // degrees * 100
    let new_est_angle = (49 * (est_angle + gyro_angle_change) + accel_angle) / 50;
    return new_est_angle;
}

// Left motor connected with L293D
const motor_a_enable = AnalogPin.P13; // enable 1-2
const motor_a1 = DigitalPin.P2; // input 1
const motor_a2 = DigitalPin.P12; // input 2

// Right motor connect with L293D
const motor_b_enable = AnalogPin.P14; // enable 3-4
const motor_b1 = DigitalPin.P15; // input 3
const motor_b2 = DigitalPin.P16; // input 4

function motor_coast() {
    pins.analogWritePin(motor_a_enable, 0);
    pins.digitalWritePin(motor_a1, 0);
    pins.digitalWritePin(motor_a2, 0);
    pins.analogWritePin(motor_b_enable, 0);
    pins.digitalWritePin(motor_b1, 0);
    pins.digitalWritePin(motor_b2, 0);
}

function motor_move(left: number, right: number) {
    if (left >= 0) {
        pins.analogWritePin(motor_a_enable, left);
        pins.digitalWritePin(motor_a1, 1);
        pins.digitalWritePin(motor_a2, 0);
    } else {
        pins.analogWritePin(motor_a_enable, -left);
        pins.digitalWritePin(motor_a1, 0);
        pins.digitalWritePin(motor_a2, 1);
    }
    if (right >= 0) {
        pins.analogWritePin(motor_b_enable, right);
        pins.digitalWritePin(motor_b1, 1);
        pins.digitalWritePin(motor_b2, 0);
    } else {
        pins.analogWritePin(motor_b_enable, -right);
        pins.digitalWritePin(motor_b1, 0);
        pins.digitalWritePin(motor_b2, 1);
    }
}

function setup() {
    basic.showIcon(IconNames.Happy);
    while (true) {
        while (!input.buttonIsPressed(Button.A)) {
            basic.pause(10);
        }
        if (InvMPU.find_mpu()) {
            break;
        }
        basic.showIcon(IconNames.No);
    }
    InvMPU.reset_mpu();
    basic.pause(100);
    basic.clearScreen();
    InvMPU.set_gyro_bias(5, 1, -13); // set this for your sensor
}

function control_loop() {
    let motor_on = false;
    let est_angle = read_accel_tilt_angle(); // degrees * 100 from vertical
    let last_err = 0;
    let i_err = 0;
    let last_time = input.runningTime();
    while (true) {
        let current_time = input.runningTime();
        let delta_t = current_time - last_time;
        last_time = current_time;
        est_angle = updateAngle(est_angle, delta_t);
        if (motor_on && (est_angle > 3000 || est_angle < -3000)) {
            last_err = 0;
            i_err = 0;
            motor_coast();
            motor_on = false;
        }
        let err = est_angle - TARGET_ANGLE;
        if (motor_on) {
            let d_err = (err - last_err) * 1000 / delta_t;
            last_err = err;
            i_err = i_err + err * delta_t;
            let u = err * KE + d_err * KD + i_err * KI;
            let motor_out = u / 1000;
            let motor_right = motor_out - motor_bias;
            let motor_left = motor_out + motor_bias;
            motor_move(Math.clamp(-1024, 1023, motor_left), Math.clamp(-1024, 1023, motor_right));
        } else if (err <= 500 && err >= -500) {
            motor_on = true;
        }
        basic.pause(5);
    }
}

setup();
control_loop();

Code for self-balancing micro:bit robot with FS90R servo motors



Ok, here's the code for version 1 of the self-balancing robot. I tried to make this work in block mode in MakeCode. The result was unreadable.

Only 80 lines of code. It implements the most primitive PID control algorithm. Clearly Segway and other self-balancing single wheel devices use far better algorithms.

To use this, you neeed to:

  • Build a robot with one of three InvenSense motion sensors and FS90R or other servo motors.
  • Add the InvMPU and Trig packages from the last post to custom.ts.
  • Copy and paste this code in main.ts.
  • Set the gyro bias using the values computed in the sample program from the last post.
  • Change read_gyro_angle_rate() and read_accel_tilt_angle() based on how the sensor is mounted on the robot.
  • Tune TARGET_ANGLE, KE, KD and KI.

When the happy face appears, press the A button to locate the sensor and start the bot. When the bot is near vertical, it will turn on the motors to balance. If the angle is too far off vertical, it will shut down the motors. Hold it vertical to start balancing again.

Here it is:


/**
 * Public domain. Use at your own risk!
 * Self-balancing robot controller, controlling a pair of FS90R servos.
 * Requires InvMPU and Trig package in custom.ts.
 * Update read_gyro_angle_rate() and read_accel_tilt_angle() to sensor mounting.
 * Update call to InvMPU.set_gyro_bias() with the bias of your sensor.
 */

// Tuning Parameters
const TARGET_ANGLE = 50; // forward tilt from vertical in degrees * 100
const KE = 1200;
const KD = 24;
const KI = 24;

const motor_bias = 0; // increase to reduce forward power to the right

function read_gyro_angle_rate(): number {
    InvMPU.read_gyro()
    return -InvMPU.gyro_y
}

function read_accel_tilt_angle(): number {
    InvMPU.read_accel()
    return Trig.atan2(InvMPU.accel_z, -InvMPU.accel_x); // degrees * 100
}

function updateAngle(est_angle: number, delta_t_ms: number): number {
    let accel_angle = read_accel_tilt_angle(); // degrees * 100
    let gyro_angle_rate = read_gyro_angle_rate() * 200000 / 32768; // (degrees * 100) per second
    let gyro_angle_change = gyro_angle_rate * delta_t_ms / 1000; // degrees * 100
    let new_est_angle = (49 * (est_angle + gyro_angle_change) + accel_angle) / 50;
    return new_est_angle;
}

function setup() {
    basic.showIcon(IconNames.Happy);
    while (true) {
        while (!input.buttonIsPressed(Button.A)) {
            basic.pause(10);
        }
        if (InvMPU.find_mpu()) {
            break;
        }
        basic.showIcon(IconNames.No);
    }
    InvMPU.reset_mpu();
    basic.pause(100);
    basic.clearScreen();
    InvMPU.set_gyro_bias(5, 1, -13); // set this for your sensor
}

function control_loop() {
    let motor_on = false;
    let est_angle = read_accel_tilt_angle(); // degrees * 100 from vertical
    let last_err = 0;
    let i_err = 0;
    let last_time = input.runningTime();
    while (true) {
        let current_time = input.runningTime();
        let delta_t = current_time - last_time;
        last_time = current_time;
        est_angle = updateAngle(est_angle, delta_t);
        if (motor_on && (est_angle > 3000 || est_angle < -3000)) {
            last_err = 0;
            i_err = 0;
            pins.digitalWritePin(DigitalPin.P15, 0);
            pins.digitalWritePin(DigitalPin.P16, 0);
            motor_on = false;
        }
        let err = est_angle - TARGET_ANGLE;
        if (motor_on) {
            let d_err = (err - last_err) * 1000 / delta_t;
            last_err = err;
            i_err = i_err + err * delta_t;
            let u = err * KE + d_err * KD + i_err * KI;
            let motor_out = u / 10000;
            pins.servoWritePin(AnalogPin.P15, 90 - Math.clamp(-90, 90, motor_out - motor_bias)); // R
            pins.servoWritePin(AnalogPin.P16, 90 + Math.clamp(-90, 90, motor_out + motor_bias)); // L
        } else if (err <= 500 && err >= -500) {
            motor_on = true;
        }
        basic.pause(5);
    }
}

setup();
control_loop();


See the next post for a version of this code that works with DC motors.

Tuesday, November 7, 2017

InvenSense MPU-9255/MPU-9250/MPU-6500 gyroscope accelerometer and compass



The BBC micro:bit does not have a builtin gyroscope and the self-balancing robot requires one.

Among the many options, the MPU-6500/MPU-9250/MPU-9255 based breakout boards are probably among the cheapest motion sensors available. The MPU-6500 costs SGD9.90 from a local retail shop and is available online for much less. The differences between the models are:

  • MPU-6500: gyroscope and accelerometer, 6 axis.
  • MPU-9250: gyroscope, accelerometer and compass, 9 axis
  • MPU-9255: same as MPU-9250 (almost)

The gyroscope and accelerometer are identical across all three models. Each axis has 16 bits of resolution. The gyroscope measures up to +- 2000 degrees per second. The accelerometer measures up to +-16g. The builtin accelerometer on the micro:bit only has 11 bits of resolution and a range of +-4g.

There are no drivers or library for these chips for the micro:bit. InvenSense provides product specification and register map documents, enough to figure out how to interface with the chips. I can ignore the  advanced features like the FIFO buffer, interrupts and the digital motion processor. I just need to configure the chips, read the gyroscope and accelerometer values.

So, if you need a cheap gyroscope and accelerometer that works with micro:bit, here's a working library you can add to your code.

Hardware instructions
  • Connect VCC and GND to the 3V and GND pins on the micro:bit respectively.
  • Connect SCL and SDA to pin 19 and 20 on the micro:bit respectively. These are the default I2C pins.
  • Connect AD0 to GND to select the default I2C address.

Software instructions
  • Create a new project on MakeCode.
  • Click on the arrow at the bottom left to show the simulator.
  • Click "{} Javascript" to switch to Javascript mode.
  • Click on "Explorer" to expand the Explorer tree.
  • Click on the "+" on the Explorer bar to add a custom.ts script.
  • Click "Go ahead!" on the "Add custom blocks?" prompt.
  • Now, it should be showing the custom.ts script. Delete all the default code.
If your project already has a custom.ts script, ignore the above instructions. Just switch to that script.

Now copy and append the code below to the end of your custom.ts.

Update: reset_mpu() now takes two parameters gyro_lpf and accel_lpf to control the digital low pass filter.

/**
 * Public domain. Use at your own risk!
 * Simplified interface for InvenSense MPU-6500, MPU-9250, MPU-9255
 */
//% weight=90 color=#0040A0
namespace InvMPU {
    export const MPU_6500_ID = 0x70;
    export const MPU_9250_ID = 0x71;
    export const MPU_9255_ID = 0x73;

    const MPU_ADDR = 0x68;
    const WHO_AM_I = 0x75;
    const REG_PWR_MGMT_1 = 0x6b;
    const REG_SIGNAL_PATH_RESET = 0x68;
    const REG_USER_CTRL = 0x6a;
    const REG_ACCEL_XOUT_H = 0x3b;
    const REG_ACCEL_YOUT_H = 0x3d;
    const REG_ACCEL_ZOUT_H = 0x3f;
    const REG_GYRO_XOUT_H = 0x43;
    const REG_GYRO_YOUT_H = 0x45;
    const REG_GYRO_ZOUT_H = 0x47;
    const REG_CONFIG = 0x1a;
    const REG_GYRO_CONFIG = 0x1b;
    const REG_ACCEL_CONFIG = 0x1c;
    const REG_ACCEL_CONFIG2 = 0x1d;
    const XG_OFFSET_H = 0x13;
    const YG_OFFSET_H = 0x15;
    const ZG_OFFSET_H = 0x17;

    function mpu_read(reg: number): number {
        pins.i2cWriteNumber(MPU_ADDR, reg, NumberFormat.UInt8BE, true);
        return pins.i2cReadNumber(MPU_ADDR, NumberFormat.UInt8BE, false);
    }

    function mpu_read_int16(reg: number): number {
        pins.i2cWriteNumber(MPU_ADDR, reg, NumberFormat.UInt8BE, true);
        return pins.i2cReadNumber(MPU_ADDR, NumberFormat.Int16BE, false);
    }

    function mpu_write(reg: number, data: number) {
        pins.i2cWriteNumber(MPU_ADDR, reg << 8 | (data & 0xff), NumberFormat.UInt16BE);
    }

    function mpu_write_int16(reg: number, data: number) {
        mpu_write(reg, (data >> 8) & 0xff);
        mpu_write(reg + 1, data & 0xff);
    }

    /**
     * Contains one of MPU_6500_ID, MPU_9250_ID, MPU_9255_ID or zero.
     */
    //% block
    export let sensor_id = 0;

    /**
     * Look for a MPU-6500, MPU-9250 or MPU-9255.
     * Returns true if the MPU was found.
     * The MPU id is in sensor_id.
     */
    //% block
    //% weight=99
    export function find_mpu(): boolean {
        sensor_id = mpu_read(WHO_AM_I);
        return sensor_id == MPU_9255_ID || sensor_id == MPU_9250_ID || sensor_id == MPU_6500_ID;
    }

    /**
     * Reset the MPU and configure the gyroscope to +- 2000 degrees/second and the accelerometer to +-16g.
     * The low pass filter settings control how sensitive the sensors are to quick changes.
     * In order of increasing sensitivity: 6, 5, 4, 3, 2, 1, 0, 7
     * Info from MPU-9250 register map document.
     * @param gyro_lpf Gyroscope low pass filter setting, eg: 0
     *   7: 8kHz sampling rate, 36001Hz bandwidth, 0.17ms delay.
     *   0: 8kHz sampling rate, 250Hz bandwidth, 0.97ms delay.
     *   1: 1kHz sampling rate, 184Hz bandwidth, 2.9ms delay.
     *   2: 1kHz sampling rate, 92Hz bandwidth, 3.9ms delay.
     *   3: 1kHz sampling rate, 41Hz bandwidth, 5.9ms delay.
     *   4: 1kHz sampling rate, 20Hz bandwidth, 9.9ms delay.
     *   5: 1kHz sampling rate, 10Hz bandwidth, 17.85ms delay.
     *   6: 1kHz sampling rate, 5Hz bandwidth, 33.48ms delay.
     * @param accel_lpf Accelerometer low pass filter setting, eg: 0
     *   7: 1kHz sampling rate, 420Hz 3dB bandwidth, 1.38ms delay.
     *   0: 1kHz sampling rate, 218.1Hz 3dB bandwidth, 1.88ms delay.
     *   1: 1kHz sampling rate, 218.1Hz 3dB bandwidth, 1.88ms delay.
     *   2: 1kHz sampling rate, 99Hz 3dB bandwidth, 2.88ms delay.
     *   3: 1kHz sampling rate, 44.8Hz 3dB bandwidth, 4.88ms delay.
     *   4: 1kHz sampling rate, 21.2Hz 3dB bandwidth, 8.87ms delay.
     *   5: 1kHz sampling rate, 10.2Hz 3dB bandwidth, 16.83ms delay.
     *   6: 1kHz sampling rate, 5.05Hz 3dB bandwidth, 32.48ms delay.
     */
    //% block
    //% weight=98
    export function reset_mpu(gyro_lpf=0, accel_lpf=0) {
        control.assert(gyro_lpf >= 0 || gyro_lpf <= 7, "gyro_lpf must be between 0 and 7");
        control.assert(accel_lpf >= 0 || accel_lpf <= 7, "accel_lpf must be between 0 and 7");
        mpu_write(REG_PWR_MGMT_1, 0x80); // H_RESET, internal 20MHz clock
        mpu_write(REG_SIGNAL_PATH_RESET, 0x7); // GYRO_RST | ACCEL_RST | TEMP_RST
        mpu_write(REG_USER_CTRL, 0x1); // SIG_COND_RST
        mpu_write(REG_CONFIG, gyro_lpf); 
        mpu_write(REG_GYRO_CONFIG, 0x18); // GYRO_FS_SEL = 3, +-2000 dps, DLPF on
        mpu_write(REG_ACCEL_CONFIG, 0x18); // ACCEL_FS_SEL = 3, +-16g
        mpu_write(REG_ACCEL_CONFIG2, accel_lpf); // DLPF on
    }

    /**
     * Gyro X axis value after calling read_gyro().
     * Value from -32768 to 32767.
     */
    //% block
    export let gyro_x = 0;

    /**
     * Gyro Y axis value after calling read_gyro().
     * Value from -32768 to 32767.
     */
    //% block
    export let gyro_y = 0;

    /**
     * Gyro Z axis value after calling read_gyro().
     * Value from -32768 to 32767.
     */
    //% block
    export let gyro_z = 0;

    /**
     * Read all three gyroscope axis values and store them in gyro_x, gyro_y and gyro_z.
     */
    //% block
    //% weight=95
    export function read_gyro() {
        gyro_x = mpu_read_int16(REG_GYRO_XOUT_H);
        gyro_y = mpu_read_int16(REG_GYRO_YOUT_H);
        gyro_z = mpu_read_int16(REG_GYRO_ZOUT_H);
    }

    /**
     * Accelerometer X axis value after calling read_accel().
     * Value from -32768 to 32767.
     */
    //% block
    export let accel_x = 0;

    /**
     * Accelerometer Y axis value after calling read_accel().
     * Value from -32768 to 32767.
     */
    //% block
    export let accel_y = 0;

    /**
     * Accelerometer Z axis value after calling read_accel().
     * Value from -32768 to 32767.
     */
    //% block
    export let accel_z = 0;

    /**
     * Read all three accelerometer values and store them in accel_x, accel_y and accel_z.
     */
    //% block
    //% weight=94
    export function read_accel() {
        accel_x = mpu_read_int16(REG_ACCEL_XOUT_H);
        accel_y = mpu_read_int16(REG_ACCEL_YOUT_H);
        accel_z = mpu_read_int16(REG_ACCEL_ZOUT_H);
    }

    export let var_x = 0;
    export let var_y = 0;
    export let var_z = 0;

    /**
     * Gyro X axis bias after calling get_gyro_bias().
     */
    //% block
    export let gyro_x_bias = 0;

    /**
     * Gyro Y axis bias after calling get_gyro_bias().
     */
    //% block
    export let gyro_y_bias = 0;

    /**
     * Gyro Z axis bias after calling get_gyro_bias().
     */
    //% block
    export let gyro_z_bias = 0;

    /**
     * Compute the gyro bias for all three axis. The bias for each axis is the average of 100 samples.
     * Returns true if the sensor is steady enough to calculate the bias.
     * The bias values are store in gyro_x_bias, gyro_y_bias and gyro_z_bias.
     */
    //% block
    //% weight=97
    export function compute_gyro_bias(): boolean {
        mpu_write_int16(XG_OFFSET_H, 0);
        mpu_write_int16(YG_OFFSET_H, 0);
        mpu_write_int16(ZG_OFFSET_H, 0);
        const N = 100;
        const MAX_VAR = 40;
        let sum_x = 0, sum_y = 0, sum_z = 0;
        let xs: number[] = [], ys: number[] = [], zs: number[] = [];
        for (let i = 0; i < N; i++) {
            let x = mpu_read_int16(REG_GYRO_XOUT_H);
            let y = mpu_read_int16(REG_GYRO_YOUT_H);
            let z = mpu_read_int16(REG_GYRO_ZOUT_H);
            sum_x += x;
            sum_y += y;
            sum_z += z;
            xs.push(x);
            ys.push(y);
            zs.push(z);
            basic.pause(1);
        }
        let mean_x = sum_x / N;
        let mean_y = sum_y / N;
        let mean_z = sum_z / N;
        var_x = 0;
        var_y = 0;
        var_z = 0;
        for (let i = 0; i < N; i++) {
            let dx = xs[i] - mean_x;
            var_x = var_x + dx * dx;
            let dy = ys[i] - mean_y;
            var_y = var_y + dy * dy;
            let dz = zs[i] - mean_z;
            var_z = var_z + dz * dz;
        }
        var_x = var_x / N;
        var_y = var_y / N;
        var_z = var_z / N;
        if (var_x > MAX_VAR || var_y > MAX_VAR || var_z > MAX_VAR) {
            return false;
        }
        gyro_x_bias = mean_x;
        gyro_y_bias = mean_y;
        gyro_z_bias = mean_z;
        return true;
    }

    /**
     * Set the gyro bias. The bias can be calculated by calling get_gyro_bias().
     * @param x_bias Bias in the X direction, eg: 0
     * @param y_bias Bias in the Y direction, eg: 0
     * @param z_bias Bias in the Z direction, eg: 0
     */
    //% block
    //% weight=96
    export function set_gyro_bias(x_bias: number, y_bias: number, z_bias: number) {
        mpu_write_int16(XG_OFFSET_H, -2 * x_bias);
        mpu_write_int16(YG_OFFSET_H, -2 * y_bias);
        mpu_write_int16(ZG_OFFSET_H, -2 * z_bias);
    }
}

Switch back to main.ts and reload your project. Your main code can now access blocks and javascript that talks to the motion sensor chips.

The InvMPU package contains these functions and they are also available as blocks from the blocks interface of MakeCode:

  • find_mpu(): find one of the three MPUs on the I2C bus and returns true if found.
  • reset_mpu(): reset the MPU and configure it to +-16g and 2000 degrees per second.
  • compute_gyro_bias(): compute the gyroscope bias. The sensor must be stationary.
  • set_gyro_bias(): set the gyroscope bias to values computed in previous calls in get_gyro_bias().
  • read_gyro(): read all three gyroscope axis values.
  • read_accel(): read all three accelerometer values.

One more thing, we really want to know the angle the sensor or robot is tilted from vertical. The axis parallel to the wheels is fixed. We can calculate the angle from the other two axes of the accelerometer using the inverse tangent function atan2(). Unfortunately, the micro:bit does not provide this function or support floating point numbers.

No worries. Here my little approximation of atan2() that takes two signed 16 bits values from the accelerometer and returns 100 times the angle in degrees. For example, if Trig.atan2() returns 2050, it means the angle is 20.5 degrees.

Copy and append the Trig module for micro:bit to the end of your custom.ts.

Here's sample code that finds the mpu, resets it, computes the gyro bias, sets the gyro bias and prints values from the gyroscope and accelerometer and the tilt angle on the serial line.

BTW, the bias values are reasonably stable for each hardware breakout board. I use the bias values calculated by the sample program in the balancing code so that I don't have to recompute the bias each time I turn the robot on.

let angle = 0
basic.showIcon(IconNames.Happy)
while (true) {
    while (!(input.buttonIsPressed(Button.A))) {
        basic.pause(100)
    }
    basic.showIcon(IconNames.Diamond)
    if (!(InvMPU.find_mpu())) {
        if (InvMPU.sensor_id != 0) {
            serial.writeLine("Unknown sensor id " + InvMPU.sensor_id)
        }
        serial.writeLine("Cannot find MPU-6500, MPU-6500 or MPU-9255")
        basic.showIcon(IconNames.No)
    } else {
        if (InvMPU.sensor_id == InvMPU.MPU_6500_ID) {
            serial.writeLine("MPU-6500")
        }
        if (InvMPU.sensor_id == InvMPU.MPU_9250_ID) {
            serial.writeLine("MPU-9250")
        }
        if (InvMPU.sensor_id == InvMPU.MPU_9255_ID) {
            serial.writeLine("MPU-9255")
        }
        InvMPU.reset_mpu()
        basic.pause(2000)
        basic.clearScreen()
        if (InvMPU.compute_gyro_bias()) {
            serial.writeLine("X variance " + InvMPU.var_x)
            serial.writeLine("Y variance " + InvMPU.var_y)
            serial.writeLine("Z variance " + InvMPU.var_z)
            serial.writeLine("X bias " + InvMPU.gyro_x_bias)
            serial.writeLine("Y bias " + InvMPU.gyro_y_bias)
            serial.writeLine("Z bias " + InvMPU.gyro_z_bias)
            InvMPU.set_gyro_bias(InvMPU.gyro_x_bias, InvMPU.gyro_y_bias, InvMPU.gyro_z_bias)
            break;
        } else {
            basic.showIcon(IconNames.Angry)
        }
    }
}
basic.showIcon(IconNames.Yes)
while (true) {
    InvMPU.read_gyro()
    InvMPU.read_accel()
    angle = Trig.atan2(InvMPU.accel_z, 0 - InvMPU.accel_x)
    serial.writeLine("Gyro: " + InvMPU.gyro_x + " " + InvMPU.gyro_y + " " + InvMPU.gyro_z + " Angle: " + angle + " Accel: " + InvMPU.accel_x + " " + InvMPU.accel_y + " " + InvMPU.accel_z)
    basic.pause(100)
}


Coming up, code for the self-balancing robot.

Public domain. 

Use at your own risk!


Self-balancing robot version 2

Every project must include something new.

I wasn't happy with version 1.

Here's version 2, completely different:


Parts:


It stays up for over half an hour on smooth floors! It also doesn't roam around as much as version 1.


View from the top.


The big differences between version 2 and version 1 are:

  • rigid frame and motor mounts instead of Lego pieces
  • hobby DC motors instead servo motors
  • two battery holders to power the motors separately from everything else
  • L293D motor controller

The L293D is an ancient motor controller from many decades ago. It isn't energy efficient, runs hot and can only provide 600mA of sustained current per channel. However, that is more than sufficient because I don't need to run the motors at full power at all. Besides, L293D has separate power and motor pins, builtin clamping diodes to protect the chip from motor back emf and over-temperature protection.

The various data sheets (STmicroelectronics , Texas Instruments) have all the application information needed to use the chip. Even better, check out this guide.

First, I prototyped the circuit on the breadboard on the donor chassis:



Then I squeezed the DIP socket, capacitors, a switch and connectors on a tiny perfboard I happened to have. The two connectors on the right are for the two motors and the two connectors on the left are for motor and logic power. The three wires on each side together control a motor.


The back doesn't look so nice. And I really do not recommend using such a small perfboard.


The micro:bit works with 2V to 3.6V and I am powering it with 2.7V. The L293D needs at least 2.3V for high input and the micro:bit drives high outputs very close to its 2.7V supply voltage. That works. However, the L293D needs at least 4.5V for the logic power supply pin according to the data sheet. I should really use a 5V step-up regulator. But it seems to work with 2.7V and that will do for now. This is just a toy after all.

The code is mostly the same as version 1. Instead of using the servo apis to control the servo motor, each DC motor is controlled by applying PWM signals to the motor enable pin (grey line) and setting the correct output to two other lines (yellow and green).

Finally, the PID controller's parameters need to be retuned by hand.

Yeah, I was going to post the code.

But first, I have to talk about the 9-axis MPU-9255 gyroscope, accelerometer and compass that is used in both versions of the robot. Coming up.

Sunday, October 29, 2017

Self-balancing robot version 1


I got my hand on a few micro:bits and a Kitronic :MOVE mini buggy. They were meant for my kids. But I had always wanted to build a remote controlled self-balancing robot. What's the easiest way to get something working? Repurpose parts from the buggy, add Lego and some code!

Here's version 1. It is fragile and wobbly. With a simple PID controller written in Javascript using makecode/PXT, it self-balances on carpet for over 20 minutes but doesn't do so well on hard floors.


I use a second micro:bit to instruct the robot to turn using the micro:bit's radio service. Turn too much and it falls over!


The bot:


Stripped of the Lego frame:


As you can see, it isn't exactly as "kid-friendly" as just adding Legos.

I had to add a digital gyroscope (MPU-9255), an edge connector and solder some connectors to get access to the I2C pins. See parts list below.

The hardware turns out to be too flimsy. The motors do not stay in position. The wheels have limited friction on hard floors. Tuning the PID parameters was hard and time consuming with so many sources of errors.

On the software side, I had to read the MPU-9255 specification and write code to talk to it using I2C. There is no existing makecode module for that 9-axis gyroscope/accelerometer/compass chip. The version of Javascript in makecode does not support floating point or trigonometric functions. Certainly, this can be made more "kid-friendly".

In the end, I was just amazed that it somewhat self-balanced on carpet for more than 20 minutes. It is not anywhere near Segway stable, which is not surprising given the rather primitive control algorithm.

Parts:
The servo:lite board isn't necessary. It holds 3xAAA batteries to generate regulated 3.3V volts for both the micro:bit and the servos. The micro:bit can actually run off 2xAAA batteries directly and the servos can be powered unregulated with up to 6V (4xAA batteries).

Version 3 is the updated version 1 without the servo:lite board and Legos.

Links to all the micro:bit robot posts:


Friday, January 27, 2017

2016 Bicycle Tour of the Alps Equipment Review

Bicycle


In decreasing order of importance:
  • Mountain bike cleats and shoes. Any walkable cleats and shoes will do. Add five pounds of muscles. Unless you are very strong, don't try to climb the Alps without them. Practise before the tour.
  • Deore 22-30-40 crankset, XT 11-36 cassette. With cleats, this gear range allows me to handle 15% grade climbs.
  • Kool Stop Dura 2 salmon colored brake pads. Excellent dry and wet performance for 50km/h 15% grade descents. They won't last as long as the stock Shimano pads though.
  • Caliper brakes. They stop well, are easy to modulate and easy to adjust. They are also not noisy and they don't warp.
  • 28mm Schwalbe Supreme tires. These tires aren't light or durable. But they have puncture resistant linings and grip well in the wet. Too bad they have been discontinued.
  • Velocity A23 32H/36H rims. We rode gravel, dirt, cobble stones and potholes with extra load. The wheels easily survived. I didn't have to worry about wheel failure.
  • Lynskey sportive. Can't go wrong with titanium. Still looks as good as new.
  • Soma Smoothie steel fork. I couldn't find a titanium fork. This steel fork has the same geometry as the original Lynskey carbon fiber fork.

Bags


I used a saddle mounted Carradice Barley and handlebar mounted Montbell Dry Front Bag 8.  Both bags are water resistant. The total capacity is about 16 liters and I only needed around 12 liters of space. The bicycle feels balanced with the weight split between the front and the back. Together, they do not interfere with the handling feel of the bicycle. In short, both works and are good enough.

GPS Tracker


In the past, my Garmin 500 had locked up a few times and I had to full reset it to work again. Furthermore, I couldn't upload the data without a computer. So, I bought a Garmin Vivoactive HR to supplement it for the tour.

The Vivoactive HR's recording and wireless uploading functions worked well. The batteries lasted ten hours of riding although the device went into low-power mode at the end of the day. The barometer wasn't accurate without a temperature sensor. Between the three of us, the four different Garmin devices recorded big differences in elevation climbed, differing by more than 50%.

It turned out that I didn't actually need to know my heart rate during the rides. It was also less convenient to read from the wrist-worn Vivoactive than the bicycle mounted Garmin 500.

I have since retired the Garmin 500 and I am using the Vivoactive HR for running and biking. Next time, I would just strap the Vivoactive HR to the bicycle's handlebar.

Cameras


I didn't have a small portable camera and I didn't want to spend a thousand dollars on a camera just for the tour. So, I used the cameras on the iPhone 5S and Samsung S6. They take better pictures than my old Canon S100 and do not require another battery charger. But the phones didn't support raw captures (then) and that was something I was willing to compromise.

The iPhone 5S and the Samsung S6 each have its own strengths and issues.

The biggest factor about using a phone while riding is the ergonomics. Samsung S6 wins big time here. Just double tap the home button and it fires up the camera app. The volume button can then be used to take a picture. I can take a picture without looking at the phone or touching the screen. The iPhone requires a tap on the home button, a swipe at the right place and then another tap on the screen to take a picture. It is harder to use the iPhone to take pictures while riding.

The Samsung S6 has visibly better resolution by far (16 vs 8 megapixels). The 16:9 widescreen aspect ratio is excellent for landscapes.

The iPhone's colors are subjectively better and more consistent. The Samsung's colors can get washed out or simply tinted in some random way. The Samsung's default camera app also does weird things to people's faces. Faces look over-processed and plasticky. The phone won't allow me to change the camera app that is triggered by double-tapping the home button. So, it isn't convenient to use a different app.

The iPhone managed to record the GPS location all the time. The Samsung can't lock in to the GPS signal in time for any shot. None of the Samsung shots had any GPS information.

After the trip, I noticed that I should have turned off Auto HDR on the iPhone while riding. Auto HDR doesn't work if you are moving fast. The pictures were a little blur even in bright light. When stationary, Auto HDR worked great, saving many blown-out skies.

Despite the problems, the phone cameras have produced many good pictures on the trip and I'm quite happy with them.


Wednesday, January 25, 2017

2016 Bicycle Tour of the Alps


July 6th to July 16th
11 days
900km
15 mountain passes
18,000m of climb

To remind myself to do something crazy once in a while.


Many thanks to my wonderful wife for taking care of the kids while I was away.


Day 1, July 6th


    This is way harder than I thought.

Thoughts kept racing through my mind.

    This is even harder than the army. 

It was the first riding day of the tour. It wasn't going well. Arturo and I lost our way riding from the hostel to the Luzern train station. Piaw and Arturo were clearly faster than me. I must keep up. I didn't want to be last and lost.

    What have I gotten myself into?

The plan was to ride from Luzern to Sarnen, climb 1500m to Melchsee-Frutt, ride to Engstlenalp, then Meiringen, and finally climb 700m to Rosenlaui. We had already booked our stay at Piaw's favorite Rosenlaui Hotel. That was the one day we wouldn't call it a day because we were too tired.

    2500m climb...

Luzern to Sarnen was flat. The gravel and dirt trails didn't bother me. Then we started climbing.

    8%... 10%... 12% grade?

My usual training route was flat. I didn't know what to expect. I also didn't know how to climb.

    My heart rate is too high.

If I pedalled faster, I would overwork my heart. If I pedalled slower, I would overwork my legs. No speed was right. Yet, Piaw and Arturo were way ahead of me. I wasn't fit enough!

Piaw must have sensed my pain. When we regrouped at the Stockalp water fountain, he suggested that I take the cable car up to Melchsee-Frutt. Game over already? "No, I'll keep riding, " I replied.

Piaw and Arturo took off effortlessly. Soon, I couldn't see them anymore.

    My heart rate is still too high.

I had to slow down.

    14% grade?

    I can walk my bike faster than I can ride. 

"My legs are cramping," I reluctantly messaged at about 1500m elevation.

"Ride down to Sarnen NOW!" Piaw screamed back instantly.

Oh, that's what plan C was. I must reach Sarnen in time to take the train to Meiringen and catch the last bus up to Rosenlaui. I rapidly descended 21km back to Sarnen. Sarnensee looked pretty on the way down.

    What about tomorrow?

I was hungry. Dinner at Rosenlaui was awesome.

"Tomorrow, we'll get you lower gears in Grindelwald," said Piaw.













Day 2, July 7th


The next morning, I ate a huge breakfast. Perhaps I didn't have enough energy the previous day?

Somehow, I managed to climb 600m to Grosse Scheidegg without being left behind. The weather was great and the view was awesome.

The bike shop at Grindelwald didn't have what I needed. Piaw made a few calls and we rushed to the Riem Bike in Interlaken. I got there at noon. They were going to close for lunch. But Piaw and Arturo had charmed them into helping us before I got there.

The mechanic carefully replaced my 30-39-50 crank with a Deore 22-30-40 crank, adjusted the derailleurs, added spacers and shortened the chain. He took the bike for a ride and adjusted it some more. I was impressed.

Piaw's Independent Cycle Touring book advised against equipment changes right before or during a tour for good reasons. I had not used clip-in and cleats before. But I was desperate. Piaw said it would feel like I had added five pounds of muscle. Besides, my trekking pedals were half platform and half clip-in. If I couldn't make it up Rosenlaui that afternoon, I might as well pack up and go home. I bought new shoes and cleats. It was a small price to pay.

Pedalling didn't feel that different for the rest of the day. We hugged the southern shore of Brienzersee and rode east into the valley for Meiringen. It was mostly flat. We visited Aareschlucht and went to Lammi restaurant for my "first dinner", a full meal of delicious homemade sausages and fries. I was hungry even though I had been eating the whole day.

Piaw was the first to head up the hill to Rosenlaui followed by Arturo. I followed behind spinning at 90rpm in the lowest gear. Soon, I was all alone. The bus had taken the same route up the hill the previous day. It was going to be a relentless climb at eight to thirteen percent grades until it plateaued after crossing a bridge three quarters of the way there.

Half way up, Arturo shouted to me from the side of the the road, "Fountain!"

I was so focused on the climb that I had not seen the fountain or Arturo standing next to it. Finally, the bridge came into view and I was relieved. May be this could work after all.












Day 3, July 8th


The third day was the real test. We were descending to Innertkirchen in the valley to mail anything we didn't need for the rest of the trip. Then we would climb 1500m to Grimsel Pass. This alternate valley and mountain pass pattern would be the template for the rest of the trip. We were going to climb as many mountain passes as the weather allowed.

Despite muscle aches in unexpected places, I was feeling more optimistic. At breakfast, I loaded up on ham, cheese, bread and coffee, almost as much as Piaw. My training rides were much shorter and less intense. Eating wasn't an issue during the ride. Then I recalled that I was cold on the first day, which was a sign of low blood sugar. But I had no appetite, not even for chocolate because I probably didn't have enough salt. I reminded myself to eat more and add salt tablets in my water bottle.

We stopped for a supermarket lunch just two hours after breakfast, after only 400m of climb. Piaw and Arturo had figured out that that was the last supermarket before the pass. We ate more bread, ham, cheese and chocolate. We also picked up salted peanuts, which increased my appetite.

As usual, Piaw and Arturo went ahead, up Grimsel Pass. I stuck to my strategy of spinning at 90rpm at the lowest gear. But it wasn't working. It still required more effort than I knew was sustainable. After all those changes, something was still not right. 1500m up Grimsel Pass was more than what I climbed each of the first two days. Eventually, I made it there far behind Piaw and Arturo. But my relieve at reaching the pass was short-lived.

"Piaw had booked us a room at Hotel Tiefenbach," said Arturo. "It is on the other side of Furka Pass."

What pass? That meant descending 400m and climbing another 600m! Oh, that would make it at 2100m day!

    Stop calculating!

    Don't think.

    Eat more chocolate.

I was cold and only probably partly due to the colder weather at the pass. I promptly ate all my remaining chocolate. It was only 600m more. I did 700m at the end of the day before.

    Stop obsessing with numbers.

   Just keep going.

The little voice in my head was back.














Day 4, July 9th


"You look like a hamster in a running wheel," said Arturo.

"What?"

I was spinning at 90rpm. Many cycling articles on the internet said that spinning at 90rpm was better than mashing the pedal at 60rpm. That was how I rode when I trained. With 22 teeth chainring and 36 teeth sprocket, my speed was just 7 km/h. I was like a hamster, spinning fast and yet not make much progress. Arturo climbed at around 60rpm.

The fourth day turned out to be an easy day. We climbed 600m to St. Gotthard Pass, taking the scenic old cobble stone road. This was followed by a long 75km descent to Bellinzona, an 1800m drop from the pass. The temperature in the valley was around 35 degrees Celcius. Piaw and Arturo looked roasted and were happy to go visit main castle Castelgrande instead of riding. Then we climbed 150m to our B&B at Grono for cooler weather. None of the places we stayed at had air-conditioners. It made sense to stay at higher elevations.













Day 5, July 10th


Each night, during and after dinner, Piaw and Arturo would discuss the pros and cons of possible routes for the next day. Mostly the forecasted weather would be the dominant factor. I would listen but sometimes got lost as they switched between German and Italian names. But I would always try to figure out the magic number, the total amount of climb.

For the fifth day, the plan was go over San Bernardino Pass and may be Passo dello Spluga. May be? Yeah, right. That would be 2300m of climb. This was almost as hard as the plan for the first day. I couldn't simply repeat what I did the last two days. It was time to try something else.

I went up three sprockets and slowed down my cadence.

    Oh...

Suddenly, I could keep up with Piaw!

A slower cadence allowed me to focus on pulling and reduce the pushing. It also magically reduced the load on my heart.

Still, I wasn't convinced that I could last the whole day. I kept my effort at sustainable levels even if I could have gone a bit faster. When I finally reached the hotel at Campodolcino after climbing 2350m for the day, I thought, may be I would be able to complete my tour after all.

That night, Piaw's wife didn't ask me if I could keep up.
















Day 6, July 11th


By the sixth day, I was accustomed to the daily routine.

Piaw and Arturo had toured this area at least twice before and were familiar with the routes and passes.  I would have never done this on my own the first time. Now, I knew what to expect. As long as I followed my checklist, I figured I could make it through the day. The 3.5 hour 1400m climb up Maloja Pass didn't feel too tiring. My muscles had stopped aching. I was really enjoying the tour.

During our supermarket lunch at Silvaplana, we had to decide whether to head to Bernina Pass or Zernez. With Piaw's style of touring, we did not book any lodging in advance. We did not even know which way we would be heading. We could change our plans at the last minute depending on how we felt and what we thought the weather was going to be. We decided to go over Bernina Pass (700m climb), Livigno Pass (200m climb) and head for Livigno. It looked far on the map. Zernez didn't look any better. I had better keep some energy in reserve just in case.

All morning, I had been trying to optimize my pedalling stoke. If my legs were properly coordinated, the strokes would feel smooth and it took less effort. Less energy was required and I can go further on the same amount of food. It was all about reducing wasted energy. Accelerate slowly. Minimize spikes in effort. Relax my shoulders. Bend the elbows. Hold my entire body steady. Don't rock left and right, or forwards and back or up and down on the seat. Try to be as smooth as possible. Any extra movement was wasting energy. I had become a machine. Engine efficiency was my goal. As I got more efficient, I climbed faster.

As we went up Bernina Pass, the sky darkened and it threatened to rain. A strong headwind made the climb even harder. Another two hours of riding to Livigno didn't look enjoyable. Fortunately, right there, half way up Bernina Pass, we came across a nice newly renovated Gasthaus & Hotel Berninahaus. We were glad to be done for the day, after just under 1800m of climb.

That evening, after speaking to Piaw, I replaced the stock Shimano brake pads with Kool Stop salmon colored brake pads, something I should have done before the trip. It would come in handy during the next few days.














Day 7, July 12th


The climbs were warm. The passes were windy and cold. The descends were cold but got gradually warmer. The valleys were warm or hot depending on the elevation. Mostly, it had been sunny. That was all about to change.

The headwind we encountered the day before had disappeared but the sky was filled with thick clouds. I felt cold. The climb up Bernina Pass and the short climb up Livigno Pass weren't hard enough. I kept my jacket on while I climbed. On the descent to Livigno, it showered both rain and hail. My fingers were cold even with long-fingered covered gloves. I was glad when the rain stopped and the clouds cleared as we approached Livigno.

The salmon brake pads competently slowed the bike down from 50km/h at nearly full brake pressure during the wet descent. The stopping power was confidence inspiring. The Shimano pads did not stop well in the wet at normal speeds on flat roads. Thank goodness I swapped the pads.

Livigno is a valley at 1800m, flanked on the east and west by mountain ridges. In winter, Livigno turns into a big ski resort. The town has many shops, hotels, restaurants and bike shops. We stopped at a few bike shops to fix Arturo's bike and look for rain gear. My hiking jacket and hiking pants were supposed to be waterproof. All I need were shoe covers. The shoe covers were expensive and looked clumsy. We didn't buy any. Besides, the sun was out again.

After going through Munt la Schera Tunnel, we climbed Ofen Pass. The weather at the pass had turned cold, dark and misty. Not much could be seen through the mist. We chatted with a nice German touring cyclist who was on his way home after an eleven month trip.  My jacket and gloves barely kept me warm until I descended 800m to Santa Maria Val Müstair. It rained in Schluderns as we reached the tourist information centre.


















Day 8, July 13th


The show Top Gear declared Stelvio Pass to be one of the best driving roads in the world. It is highest mountain pass in Italy and Switzerland at 2757m. The pass looked spectacular in the show. I was looking forward to it.

The skies were blue and the sun was out. But it was still cold at the hotel. I had worn two shirts and a pair of arm coolers. Half an hour after starting, the sun seemed hot enough that I put on sunscreen while wondering if the extra shirt was necessary.

The switchbacks along the climb were numbered in reverse order, starting from 48. The turns were the gentlest part. At turn 22, elevation 2188m, we stopped at Berghotel Franzenshöhe. It has started drizzling and we were hungry. I grabbed a hot Gulaschsuppe and coffee to warm up.

My hopes of getting good views from the pass was dashed. The rain has gotten heavier and the fog has rolled in after our slow lunch. It was too early to stop and the weather forecast for the next day was not good. We decided to climb the pass and hope the rain wouldn't turn into a thunderstorm.

I wore my jacket and headed up the mountain in the rain. An older cyclist on a mountain bike followed me closely after I overtook him. He looked like he was at least 60 years old. After I reached the pass, before I could head into some shelter, he tapped me on the shoulder, pointed to his flat front tire and promptly walked away!

I was in no mood for small talk. All I could think of was a nice warm hotel. It was raining, very windy and very cold. I added waterproof pants, long fingered gloves and a helmet to whatever I was wearing. After our mandatory pass signboard picture, we took off for the descent to escape the cold.

Three tops, two bottoms and two pairs of gloves couldn't keep the cold out. On the climb, I had not realized how cold it was because the exertion was keeping my body warm. On the sweeping high speed descent, with idling muscles, rain and 50km/h relative winds, I could hardly control my fingers and my teeth were chattering. The Italian road was also lined with potholes and I was worried about losing traction hard braking before the hairpins. This was the worst descent ever.

One hour and 1500m later, Arturo and I safely reached Bormio. Piaw, with his superior descending skills, had already booked us a hotel room. After checking into the hotel, the weather cleared up. We went for an evening stroll through the nice old town of Bormio and visit the local castle.










Day 9, July 14th


I wasn't worried about the climbs anymore. I was more worried about descents in the cold.

It turned out that I had been losing weight. The large breakfasts, three course dinners and continuous snacking throughout the day weren't enough to fuel the rides. We were probably burning more than 6000 calories a day.

I had lost an inch around my waist and lost strength in my upper body. That was why it always felt cold. At the end of the day, I would feel even colder because of low blood sugar.

Riding out of Bormio, it looked like the cold descents were over. The sun was out again, poking through the gaps of white clouds. It had snowed the night before and the mountains were partly covered with a thin layer of snow. It was absolutely gorgeous. As we climbed Gavia Pass, it got sunnier but barely warmer. At the pass, I ducked inside Rifugio Bonetta to take a look at a poster of Jobst Brandt and to warm up.

With the two pairs of gloves, two shirts, a jacket, riding shorts, pants and even a Buff headwear under the helmet, I still felt cold under the glaring sun. I didn't warm up until we reached the pizza stop near Ponte di Legno. It was 30 degress celcius.

After lunch, we climbed Tonale Pass, down past Malè and headed north east towards Bolzano. I was starting to feel cold again. Lunch and chocolates weren't enough. It was hard to estimate how much to eat when I didn't know when we were going to stop for the day.

    Food. I need to eat.

We were still discussing where to stop for the day when I saw a hotel with a gelato sign. I promptly stopped and bought a chocolate gelato.

Eventually, we found a hotel with great restaurant reviews in the small apple farming town of Brez. Locanda Alpina had the best food on the tour.

My part of the tour was coming to an end. I had to make the morning flight on July 18th from Zurich. Zurich was 300km west of Brez. The next day would take us east past Bolzano to Canazei. That was over 400km away. I needed a convenient place to take a train to Zurich.

Arturo and Piaw were planning to ride further east to Cortina d'Ampezzo towards the Austrian Alps. There were no trains from Cortina d'Ampezzo. The only feasible route was to ride to Dobbiaco early 17th morning to make a bus connection and then make a number of train connections. I could not book the tickets in advance and bike tickets might be limited on some trains. There was little margin of safety. Canazei was the furthest I should go. At worst, I could backtrack to the big city of Bolzano where there were many trains to Zurich via Innsbruck.


























Day 10, July 15th


This was the last whole day we were riding together on the tour. The 15km ride up Mendola Pass was easy. From the pass, we could look past the glacier carved Val d'Adige south of Bolzano and see the Dolomites on the other side. We had booked a room 85km away at Hotel Aurora at Alba, 2km south of Canazei in the Dolomites. We were going to descend into Bolzano and climb back up to 1700m. The vastness of the distance suddenly dawned on me.

Bolzano was hot, which was great. We had our usual bread, prosciutto, cheese and espresso lunch in the outskirts of Bolzano, skipping the busy touristy center. Then we climbed up to Karersee. Karersee was a touristy little lake with views of some Dolomites peaks in the background. After walking the lake, I had cravings for sausages but I passed because the touristy food didn't look good and we weren't climbing much anymore.

We went up Passo di Costalunga and took our obligatory pass photo. As we headed down to Canazei, I started to feel cold again! I was wishing I had grabbed sausages earlier. It would be another one and half hours of riding before we reached our hotel in Alba around 6pm. We just had another ten hour riding day.




















Day 11, July 16th


I had been to the Dolomites a few years ago and had stayed in Selva di Val Gardena right before the Sellaronda bike day. The Sellaronda bike route was a loop around the four passes Passo Sella, Passo Pordoi, Passo Compolongo and Passo Gardena. I had driven the route and the views were great. I had never thought I would be riding there.

On this final riding day, we had decided to ride 800m up Passo Sella. Traffic was heavy because it was a Saturday. This was my last mountain pass climb and would be for a long time. At the start of the tour, I felt I had to catch up with Piaw and Arturo as I lagged behind. Now, I took it easy and enjoyed the climb. The morning was cold especially in the shade of the mountain. The pass, at 2240m, was windy and even colder. The view was as awesome as I had imagined.

A few pass photos later, we descended 400m to the crossroad with the fork to Selva di Val Gardena. This was where we split. After a group photo and a quick farewell, Piaw and Arturo turned right towards Passo Gardena for the rest of their tour. I was now on my own.

I slowly headed down to Selva with the heavy Saturday traffic. Selva was crowded with tourists and cars, like when I was there the last time. Originally, I had been looking forward to having some pizza from a place I had visited before. But I was too cold. I stopped at a crowded bakery in the center of town and bought a doughnut and a slice of pizza. Then I sat outside the bakery and watched people go by.

The last leg was a fast descent through Ortisei to the Ponte Gardena train station.

My tour was over.













Epiloque


Trains departed from Ponte Gardena to Brennero hourly. From Brennero, I walked across the border into Austria and took a train to Innsbruck. At Innsbruck, I immediately went to the ticketing office to buy next day train tickets for Zurich. To my horror, there was only one bicycle slot left! That train would reach Zurich after 7pm. Luckily, I had a margin of safety.

My wife had booked me a hotel room in Innsbruck within walking distance of the train station. The hotel was new and had secured parking for bicycles. I checked in, stored my bike and went to the room to wash up. By the time I left the hotel for a walk to old town, most regular shops had closed. Nevertheless, I walked all over town, found the touristy areas and had an awesome burger with local beef for dinner.

The next day, Innsbruck was flooded with tourists. The shops were open and I shopped for toys for the kids. Then I discovered the free Innsbruck Promenade Concerts at the Imperial Palace. I would certainly be bringing the family here for a vacation.

I was strongest about two weeks after the tour. A few months later, the three pounds of weight and the inch I lost during the trip were back. So was my upper body strength. Whatever my body optimized for during the trip wasn't sustainable in my normal routine.

Six months later, memories still come flooding back whenever I go for my training rides.













Check out Piaw's trip report for the whole tour.

More photos from Piaw.
















Route


Day 1
  • 83km, 1240m climb.
  • Luzern, Sarnen, Melchtal, Stockalp, half way up Melchee-Frutt, Sarnen, train to Meiringen, bus to Rosenlaui
Day 2
  • 86km, 1640m climb.
  • Rosenlaui, Grosse Scheidegg, Grindelwald, Interlaken, Giessbach, Meiringen, Rosenlaui
Day 3
  • 60km, 2090m climb.
  • Rosenlaui, Innertkirchen, Grimsel Pass, Furka Pass, Tiefenbach
Day 4
  • 116km, 840m climb.
  • Tiefenbach, St Gotthard Pass, Bellinzona, Grono
Day 5
  • 86km, 2350m climb.
  • Grono, San Bernardino Pass, Passo dello Spluga, Campodolcino
Day 6
  • 78km, 1780m climb.
  • Campodolcino, Maloja Pass, St Moritz, Pontresina, Berninahaus
Day 7
  • 87km, 1020m climb.
  • Berninahaus, Bernina Pass, Livigno Pass (Forcola di Livigno), Livigno, Munt la Schera Tunnel, Ofen Pass (Passo del Forno), Santa Maria Val Müstair, Schluderns
Day 8
  • 58km, 1850m climb.
  • Schluderns, Stelvio Pass, Bormio
Day 9
  • 107km, 2270m climb.
  • Bormio, Gavia Pass, Tonale Pass, Brez
Day 10
  • 99km, 2260m climb.
  • Brez, Mendola Pass, Bolzano, Karersee, Passo di Costalunga (Karerpass), Canazei, Alba
Day 11
  • 44km, 770m climb.
  • Alba, Passo Sella, Selva di Val Gardena, Ponte Gardena