JIT Module 1, Lesson 3

In the last lesson we started playing around with functions to help us automate simple and repetitive tasks. In this lesson, we're going to add to what we've already learned and start creating functions that can automate more complex tasks.

Context - Mars Rover Navigation

NASA has decided to send another rover to Mars following the success of Curiosity. However, the transmission time from Earth to Mars can be anywhere from 3 to 21 minutes, making it infeasable for a human to control the rover.

What are we looking at?

What does a rover see out of its cameras? How can those camera views be translated into something that the rover can interact with? The Curiosity Rover has 2 pairs of black and white navigation cameras that view the terrain from a 45 $^\circ$ angle, which it uses to generate a steroscopic 3D view of the terrain.

Here's a similar stereoscopic rendering (with color added in) that was generated by Curiosity's predecessor, Spirit.

Representing 3D information in 2D

The stereoscopic rendering above is pretty cool looking, but in some ways, it's almost too detailed. Now, for the approximately $2.5 billion that Curiosity cost, we want to be very careful, but there are many ways to represent 3D information in 2D.

In fact, that's exactly what a contour map is, a 2D map of an area with additional markings that supply information about the 3D landscape.

In this (unrelated) color contour map, we can see that there's a valley (in blue) surrounded by several peaks (in red).

But why would we do this?

It's true that a stereoscopic landscape image is easier for our eyes to parse and translate into notions of height and depth, but it's actually much harder for a computer. With a contour map, we can much more easily instruct a program to "Avoid the color red," instead of the much more subjective "Avoid tall peaks!"

But even colors are more of a human conceit. What's even easier for a program to understand is "avoid numbers greater than 1000."

Getting started

For this exercise, we're going to simplify the terrain we're working with, but hopefully you'll begin to see that once we know how to traverse "simple" terrain, it's not too much more work to begin working on more complicated terrain.

To begin, let's import our two go-to libraries and set our plots to display inline.


In [1]:
import numpy
import matplotlib.pyplot as plt
%matplotlib inline

Importing terrain

In the resources folder, there's a subfolder called terraindata which has a CSV file that represents a simplified terrain map.

In the CSV file,

  • 0. represents passable space
  • 1. represents the location of the rover
  • 3. represents hazardous locations
  • 4. represents the desired destination
  • 5. represents impassable walls

Let's go ahead and load that data using the (by now familiar) numpy.loadtxt command, then print it out to have a look.


In [2]:
terrain = numpy.loadtxt('./resources/terraindata/terrain1.csv', delimiter=',')
print terrain


[[ 5.  5.  5.  5.  5.  5.  5.  5.  5.  5.]
 [ 5.  0.  0.  0.  4.  0.  3.  0.  0.  5.]
 [ 5.  0.  0.  3.  3.  3.  3.  3.  3.  5.]
 [ 5.  0.  0.  3.  0.  0.  3.  0.  3.  5.]
 [ 5.  3.  0.  3.  0.  0.  3.  3.  0.  5.]
 [ 5.  0.  0.  3.  0.  0.  3.  0.  0.  5.]
 [ 5.  0.  0.  0.  0.  0.  3.  3.  0.  5.]
 [ 5.  0.  0.  3.  0.  0.  0.  1.  3.  5.]
 [ 5.  3.  0.  0.  0.  0.  3.  0.  0.  5.]
 [ 5.  5.  5.  5.  5.  5.  5.  5.  5.  5.]]

There's our terrain map. As we noted above, this is a format that is easy for a computer to understand, but it's a little harder for us to read it. Since we're going to be looking at our position a bunch of times, let's write a function that will display this map in a more readable way.

Docstrings

We learned how to define functions in the last lesson, this time we're going to add one extra bit of bookkeeping to make our lives easier moving forward, the docstring.

The docstring is the help file that accompanies a function that someone has written. They're incredibly helpful, not only for when other people are reading/using your code, but for when you return to something you wrote a long time ago. To create a docstring, we're going to wrap our little help text-let in triple ''' marks on the line after we write our def function_name():

While these might seem simple at the moment, it's a very good habit to get into, especially as you begin to write longer and more complicated functions.


In [3]:
def show_map(terrain):
    '''
    show_map takes a 2D Numpy array representing a simplified terrain map and prints
    out a colorful version using numpy.imshow
    '''
    plt.figure(figsize=(10, 5))
    plt.imshow(terrain, interpolation='none')
    plt.xticks([]), plt.yticks([])
    plt.show()

We haven't seem plt.imshow before. It's a matplotlib function that displays an image. We set the interpolation='none' flag because we want to see sharp edges to make it easier to navigate.

The line plt.xticks([]), plt.yticks([]) is just for aesthetics. Try commenting it out and seeing what the map looks like.

Now, let's take a look at that terrain map.


In [4]:
show_map(terrain)


Ok, that's better! The blue dot is our rover, the orange dot is our destination, yellow dots are obstructions and the red border is (for now) impassable. But why did we bother writing out that docstring? It isn't displayed anywhere...

If you want to look at a docstring, just type

help(name_of_function)


In [5]:
help(show_map)


Help on function show_map in module __main__:

show_map(terrain)
    show_map takes a 2D Numpy array representing a simplified terrain map and prints
    out a colorful version using numpy.imshow

There we go. This way, if we forget exactly what a function does (any function, not just the ones we wrote) we can look it up this way. If you look up the docstrings of some of the numpy functions we've used in previous lessons, you'll discover that they bear a striking resemblence to the help info you find when you google them.

Moving around

Now that we can see where we're going, we have to figure out how we can move the rover. But we also need to know where the rover is. It's easy to spot on the map we displayed, but how do we help Python understand where it is?

Numpy has a(nother) handy function called where that returns the location in an array for any value that meets a condition we specify.

So let's ask numpy to find the location(s) where our terrain map is equal to 1


In [6]:
numpy.where(terrain == 1)


Out[6]:
(array([7]), array([7]))

Ok! Numpy has found a row number and a column number where terrain is equal to 1.

Whoah, whoah, stop the music, what is == ?

The double equals sign is important. Very, very important. It's how Python (and several other programming languages) distinguish between assignment and comparison.

  • Assignment is when we want to give a variable a value, like x = 5
  • Comparison is when we want to ask Python the question, "Is x equal to 5?" then we have to use x == 5

Back to the map

numpy.where informed us that the rover location is row 7, column 7. Let's start by defining a short array that will hold our location.


In [7]:
location = numpy.array([7,7])
print location


[7 7]

Now that we have our location variable, it's time to think about how we can change our location. For the moment, let's just concentrate on changing location without worrying about walls and barriers, those will come later.

Moving around

Let's think about our current position, $[7,7]$. If our location is in the format $[row, column]$, what would the indices of all of our surrounding boxes be? (That is, up, down, left and right. We're not going to deal with diagonal movement this time around)

If we move up, we'll be in the same column but our row number will decrease by one. Similarly, moving down will give us an incremented row number with the same column number. So for our starting position, the surrounding areas have these $[row,column]$ numbers.

\begin{matrix} & [6,7] & \\ [7,6] & [7,7] & [7,8] \\ & [8,7] & \end{matrix}

We can work with this. Let's say we want to move up one square, then we have to change $[7,7]$ in to $[6,7]$. To do that, all we have to do is take our original position and add the "movement" array $[-1,0]$. Let's try it.


In [8]:
location + numpy.array([-1,0])


Out[8]:
array([6, 7])

Adding multiple conditionals to functions

In the wind turbine lesson, we wrote a function that would check which units were being used in a given data file and then apply the appropriate conversion. To accomplish this, we had three if statements in a row. That got the job done, but there's a more efficient way to accomplish this: using elif

elif is a contraction of else if and it creates a better flow within a function. elif statements allow us to create a list of conditionals but we only progress down the list if the previous statement fails. Let's look at a simple example.


In [9]:
x = 5

if x < 3:
    print "Less than 3"
elif x > 3:
    print "Greater than 3"


Greater than 3

So because $x > 3$, the first if condition evaluates to false and then the elif condition is checked. If the first if condition had been true, the elif wouldn't be evaluated at all.

So what happens if none of the if or elif statements trigger? Let's check.


In [10]:
x = 3

if x < 3:
    print "Less than 3"
elif x > 3:
    print "Greater than 3"

As you might expect, nothing happens. If no conditions are tripped, then nothing happens. Sometimes that's desirable behavior, but sometimes you want to have a catch-all statement at the end. In that case, we can tack on an else condition, which is triggered only if every previous statement has failed.


In [11]:
x = 3

if x < 3:
    print "Less than 3"
elif x > 3:
    print "Greater than 3"
else:
    print "x is 3"


x is 3

So because both the if and elif statements evaluated false, we arrived at the else statement which gave us our output. You can have as many elif statements in a row as you want (although that's not always the most efficient way to go about solving a problem)

Creating our movement function

Ok, now that we've got a grip on multiple conditionals, let's put together a function that can translate our movement commands into the appropriate matrix operation. Since Python is equally comfortable with words and numbers, let's have our four commands be

  • up
  • down
  • left
  • right

Let's call the function move_rover


In [12]:
def move_rover(direction):
    '''
    move_rover takes a movement direction as a string, either 'up', 'down', 'left'
    or 'right' and then returns an appropriate 2-element numpy array which will adjust
    a location coordinate array accordingly
    '''
    if direction == 'up':
        return numpy.array([-1,0])
    elif direction == 'right':
        return numpy.array([0,1])
    elif direction == 'down':
        return numpy.array([1,0])
    else:
        return numpy.array([0,-1])

Ok! So our function will give us the array to add to our location to move in the specified direction. Let's try it out. Remember, we're still at $[7,7]$ and the positions around us are:

\begin{matrix} & [6,7] & \\ [7,6] & [7,7] & [7,8] \\ & [8,7] & \end{matrix}

So if we add the results of move_rover to location, we should end up with the appropriate adjacent index.


In [13]:
print move_rover('up')+location
print move_rover('right')+location
print move_rover('down')+location
print move_rover('left')+location


[6 7]
[7 8]
[8 7]
[7 6]

Ok! We're getting the results we expect, so we should be all set. Except there's just one thing...


In [14]:
print move_rover('pickle')+location


[7 6]

Apparently, Python thinks that 'pickle' and 'left' are the same thing. Or rather, that's what we programmed. This is something you should always remember when using else statements: you don't always know what someone is going to type into your program, but it could be something completely wacky that will produce very unexpected results.

Let's rewrite move_rover, but this time, we'll have our else statement return the array $[0,0]$, so if someone puts in an invalid command, the rover will just stay in the same place.


In [15]:
def move_rover(direction):
    '''
    move_rover takes a movement direction as a string, either 'up', 'down', 'left'
    or 'right' and then returns an appropriate 2-element numpy array which will adjust
    a location coordinate array accordingly
    '''
    if direction == 'up':
        return numpy.array([-1,0])
    elif direction == 'right':
        return numpy.array([0,1])
    elif direction == 'down':
        return numpy.array([1,0])
    elif direction == 'left':
        return numpy.array([0,-1])
    else:
        return numpy.array([0,0])

Ok, let's try this again.


In [16]:
print move_rover('up')+location
print move_rover('right')+location
print move_rover('down')+location
print move_rover('left')+location
print move_rover('football')+location


[6 7]
[7 8]
[8 7]
[7 6]
[7 7]

Good. We get the correct position from our four directions and we stay in the same spot if the command isn't recognized.

Crash avoidance

We now have a function to move the rover around, but right now, there's nothing to stop it from running right into an obstacle. We have to write another function that checks the proposed position change and compares that to our terrain map.

If the terrain isn't suitable for the rover, then we won't move there. If the terrain is passable, then we'll update our location variable with the new position and also change our terrain map to reflect that new position.

What should our crash_check function check for? Let's write out our options.

  • If space is 'clear' (represented by 0), then we can move there
  • If space is 'obstructed' (either a 3 or a 5), then we can't move there
  • If space is 'destination' (a 4), then we can move there and should probably announce that we've made it so we can stop moving.

In [17]:
def crash_check_and_move(location, movement, terrain):
    '''
    crash_check_and_move takes 3 variables: the current position, the desired movement
    to make and the current terrain map (with rover position included).  It checks
    that the desired movement is 'allowed' and if it is, performs the movement, 
    updating both position and the terrain map.  
    If the movement is 'illegal', it simply returns the position and terrain map 
    unchanged.
    If the movement will bring rover to the 'destination' it prints out a 
    congratulatory message.
    '''
    old_y, old_x = location
    new_y, new_x = location + movement
    
    if terrain[new_y][new_x] != 3 and terrain[new_y][new_x] != 5:
        terrain[new_y][new_x] = 1.
        terrain[old_y][old_x] = 0
        return numpy.array([new_y,new_x])
    
    elif terrain[new_y][new_x] == 4:
        terrain[new_y][new_x] = 1.
        terrain[old_y][old_x] = 0
        print "You've reached your destination!"
        return numpy.array([new_y,new_x])
    
    else:
        return location

There's a lot going on in this, so let's break it down a bit. Take a look at the first line after the docstring.

old_y, old_x = location

In Python, arrays can be split into multiple variables by using this syntax. You can think of it as a shortcut for the two separate commands:

old_y = location[0]
old_x = location[1]

The if statement

if terrain[new_y][new_x] != 3 and terrain[new_y][new_x] != 5:

is a combination of two conditions that we want to check. We want to check first that our new position isn't an obstruction (denoted by a $3$) and also that it isn't a wall (denoted by a $5$). The comparison != is shorthand for 'not equal to'.

So our if statement is checking that the new position is neither a $3$ nor a $5$ and if that's true, we set our location variable equal to the new coordinates and then update our terrain map. Let's take a look and see if it works.

First, let's print out our map again:


In [18]:
show_map(terrain)


Putting it all together

Now let's try moving left and then view the updated map. Since we want to hold on to the changes we've made, we assign the new location to the variable location.

To supply the direction array from our move_rover function to our crash_check_and_move function, we could save the array to a variable and then pass that variable to crash_check_and_move. Something like:

direction = move_rover('left')
location = crash_check_and_move(location, direction, terrain)

But! Since we know that as soon as we have our direction array, we're immediately going to send it to crash_check_and_move, let's kill two birds with one stone and use the move_rover('left') function as one of the commands we pass to crash_check_and_move.


In [19]:
location = crash_check_and_move(location, move_rover('left'), terrain)
show_map(terrain)


It worked! We can move around! Let's try to move left two more times so we're up against an obstruction on our left.


In [20]:
location = crash_check_and_move(location, move_rover('left'), terrain)
location = crash_check_and_move(location, move_rover('left'), terrain)
show_map(terrain)


Still looking good. Now let's make sure that the rover controls obey our rules about not moving into an obstacle location. Let's try moving left one more time and, if everything works, we should stay right where we are, since there's an obstacle in our path.


In [21]:
location = crash_check_and_move(location, move_rover('left'), terrain)
show_map(terrain)


Awesome! Let's "reset" our terrain map to our original starting point and see if we can't get the rover from start to end.

The whole journey


In [22]:
terrain = numpy.loadtxt('./resources/terraindata/terrain1.csv', delimiter=',')
location = numpy.array([7,7])
show_map(terrain)


So which commands do we have to send to the rover to get it to the orange square? It looks like we'll have to:

  • Move left three times
  • Move up once
  • Move left two times
  • Move up five times
  • Move right two times

Let's give it a shot!


In [23]:
location = crash_check_and_move(location, move_rover('left'), terrain)
location = crash_check_and_move(location, move_rover('left'), terrain)
location = crash_check_and_move(location, move_rover('left'), terrain)

location = crash_check_and_move(location, move_rover('up'), terrain)

location = crash_check_and_move(location, move_rover('left'), terrain)
location = crash_check_and_move(location, move_rover('left'), terrain)

location = crash_check_and_move(location, move_rover('up'), terrain)
location = crash_check_and_move(location, move_rover('up'), terrain)
location = crash_check_and_move(location, move_rover('up'), terrain)
location = crash_check_and_move(location, move_rover('up'), terrain)
location = crash_check_and_move(location, move_rover('up'), terrain)

location = crash_check_and_move(location, move_rover('right'), terrain)
location = crash_check_and_move(location, move_rover('right'), terrain)

show_map(terrain)


We made it!

Dig Deeper and Think

We moved the rover from its starting position to its destination, but it required typing in a bunch of commands and some pretty repetitive actions. We'll optimize things more in the next lesson, but before you head there, see if you can simplify things here first.

Try to rewrite crash_check_and_move and move_rover so that you can move the rover by just typing

crash_check_and_move(location, 'left', terrain)

In [24]:
from IPython.core.display import HTML
def css_styling():
    styles = open("../styles/custom.css", "r").read()
    return HTML(styles)
css_styling()


Out[24]: