1 Introduction to functions and robot control strategies

Sensors are at the heart of robotics. A machine without sensors cannot be a robot in our terms. The human body is replete with sensors. Our five external senses – sight, hearing, touch, smell and taste – and internal sensing such as balance and proprioception (body awareness) are all marvellously sophisticated.

For this week’s practical activities, we will be concerned with various techniques that can be used to allow a robot to use sensory information to control its actuators. We will investigate a progression of control strategies:

  1. dead reckoning – no sensor input

  2. reflex behaviour – sensors linked directly to motors according to the sense–act model

  3. deliberative behaviour – actuation depends on reasoning about sensor information and other knowledge, according to the sense–think–act model.

The first control strategy, dead reckoning, is an ‘open-loop’ control approach, since it does not use sensor input.

The second is an example of a ‘sense–act’ control strategy that you encountered earlier in the block; we will illustrate this control strategy using simulated implementations of simple Braitenberg vehicles.

Finally, there is the most complex control strategy, in which the robot deliberates on the sensor inputs in the context of other knowledge using an approach we refer to as ‘sense–think–act’. This involves reasoning and corresponds more closely to the way humans solve complex problems and plan actions in the long and short term.

But before we do that, we’ll have a look in a bit more detail at another powerful idea in computer programming: functions. You’ve already met some of these, but without much explanation. So now let’s introduce you to them for real.

1.1 Defining simple Python functions

Many of the programs we have used so far have been quite short with little, if any, reused code.

As programs get larger, it is often convenient to encapsulate several lines of code within a function. Multiple lines of code within a function can then be called conveniently from a single statement whenever they are needed.

Functions are very powerful, and if you have studied other programming courses then you may well be familiar with them.

For our purposes, the following provides a very quick overview of some of the key behaviours of Python functions. Remember that this isn’t a Python programming module per se; rather, it’s a module where we explore how to use Python to get things done. What follows should be enough to get you started writing your own functions, without creating too many bad habits along the way.

To see how we can create our own functions, let’s consider a really simple example: a function that just prints out the word Hello.

The function definition has a very specific syntax:

def FUNCTION_NAME():
    # ONE_OR_MORE_LINES_OF_CODE
    pass

A Python function requires at least one line of valid code (which does not include comments) in the function body. If we don’t know what lines of code we want just yet, the pass command is enough to create a valid program line that doesn’t actually have to do anything.

Here are some of the rules relating to the syntactic definition of a Python function:

  • the FUNCTION_NAME MUST NOT contain any spaces or punctuation other than underscore (_) characters

  • the function name MUST be followed by a pair of brackets (()), that may contain something (we’ll see what later), followed by a colon (:)

  • the body of the function MUST be indented using space or tab characters; the level of indentation of the first line sets the effective ‘left-hand margin’ for the remaining lines of code in the function

  • the body of the function must include AT LEAST one valid statement or line of code EXCLUDING comments; if you don’t want the function to do anything, but need it as a placeholder, use pass as the single line of required code in the function body.

It is good practice to annotate your function with a so-called ‘docstring’ (documentation string) providing a concise, imperative description of what the function does.

def FUNCTION_NAME():
    """"Docstring containing a concise summary of the function behaviour."""
    # ONE_OR_MORE_LINES_OF_CODE
    pass

Run the following code cell to define a simple function that prints the message Hello:

def sayHello():
    """Print a hello message."""
    print('Hello')

When we call the function, the code contained within the function body is executed.

Run the following cell to call the function:

sayHello()

Functions can contain multiple lines of code, which means they can provide a convenient way of calling multiple lines of code from a single line of code.

1.2 Passing arguments into functions

Functions can also be used to perform actions over one or more arguments passed into the function. For example, if you want to say hello to a specific person by name, we can pass their name into the function as an argument, and then use that argument within the body of the function.

We’ll use a Python f-string as a convenient way of passing the variable value, by reference, into a string:

def sayHelloName(name):
    """Print a welcome message."""
    print(f"Hello, {name}")

Let’s call that function to see how it behaves:

sayHelloName("Sam")

What happens if we forget to provide a name?

sayHelloName()

Oops… We have defined the argument as a positional argument that is REQUIRED if the function is to be called without raising an error.

If we want to make the argument optional then we need to provide a default value:

def sayHelloName(name='there'):
    """Print a message to welcome someone by name."""
    print(f"Hello, {name}")
    
sayHelloName()

If we want to have different behaviours depending on whether a value is passed for the name, then we can set a default such as None and then use a conditional statement to determine what to do based on the value that is presented:

def sayHelloName(name=None):
    """Print a message to welcome someone optionally by name."""
    if name:
        print(f"Hello, {name}")
    else:
        print("Hi there!")

sayHelloName()

Sometimes, we may want to get one or more values returned back from a function. We can do that using the return statement. The return statement essentially does two things when it is called: firstly, it terminates the function’s execution at that point; secondly, it optionally returns a value to the part of the program that called the function.

1.2.1 Activity – Defining a simple function

Run the following code cell to define a function that constructs a welcome message, displays the message and returns it:

def sayAndReturnHelloName(name):
    """Print a welcome message and return it."""
    message = f"Hello, {name}"
    print("Printing:", message)
    return message

What do you think will happen when we call the function?

Write your prediction here about what you think will happen when the function is run here before you run the code cell to call it.

sayAndReturnHelloName('Sam')

Run the above cell to call the function. Did you get the response you expected?

Discussion

Click on the arrow in the sidebar or run this cell to reveal my observations.

In the first case, a message was printed out in the cell’s print area. In the second case, the message was returned as the value returned by the function. As the function appeared on the last line of the code cell, its value was displayed as the cell output.

1.3 Setting variables to values returned from a function

As you might expect, we can set a variable to the value returned from a function:

message = sayAndReturnHelloName('Sam')

If we view the value of that variable by running the following cell, what do you think you will see? Will the message be printed as well as displayed?

Write your prediction about what you think will happen when the function is run here before you run the code cell to call it.

message

Only the value returned from the function is displayed. The function is not called again, and so there is no instruction to print the message.

To return multiple values, we still use a single return statement:

def sayAndReturnHelloName(name):
    """Print a welcome message and return it."""
    message = f"Hello, {name}"
    print("Printing:", message)
    return (name, message)

sayAndReturnHelloName('Sam')

Finally, we can have multiple return statements in a function, but only one of them can be called from a single invocation of the function:

def sayHelloName(name=None):
    """Print a message to welcome someone optionally by name."""
    if name:
        print(f"Hello, {name}")
        return (name, message)
    else:
        print("Hi there!")
    return

print(sayHelloName(), 'and', sayHelloName("Sam"))

Generally, it is not good practice to return different sorts of object from different parts of the same function. If you try to assign the values returned from the function to a particular variable, that variable could end up being defined in different ways depending on which part of the function returned the value to it.

There is quite a lot more to know about functions, particularly in respect of how variables inside the function relate to variables defined outside the function, a topic referred to as variable scope.

Variables defined within a Python are scoped according to where they are defined. Variables are in scope at a particular part of a program if they can be seen and referred to at that part of the program.

In Python, variables defined outside a function can typically be seen and referred to from within the function. Variables can also be passed into a function via the function’s arguments. But variables defined inside the function cannot be seen outside the function.

We will not consider issues of scope any further here, but it is a very powerful concept and one that any comprehensive introduction to programming should cover.

1.4 Using functions in robot control programs

Let’s now consider how we might use our functions in a robot control program.

We’ll start by considering the simple program we explored previously to make the robot trace out a square.

If you recall, our first version of this program explicitly coded each turn and edge movement, and then we used a loop to repeat the same action several times.

To get things set up correctly, load the simulator into the notebook in the usual way:

from nbev3devsim.load_nbev3devwidget import roboSim, eds

%load_ext nbev3devsim
%load_ext nbtutor

Tweak the constant value settings in the program below until the robot approximately traces out the shape of a square.

%%sim_magic_preloaded -x 200 -y 500 -a 0 -p -C --pencolor red

SIDES = 4

# Try to draw a square, ish...
STEERING = -100
TURN_ROTATIONS = 1.6
TURN_SPEED = 10

STRAIGHT_SPEED_PC = SpeedPercent(40)
STRAIGHT_ROTATIONS = 4

for side in range(SIDES):
    # Go straight
    # Set the left and right motors in a forward direction
    # and run for the specified number of forward rotations
    tank_drive.on_for_rotations(STRAIGHT_SPEED_PC,
                                STRAIGHT_SPEED_PC,
                                STRAIGHT_ROTATIONS)

    # Turn
    # Set the robot to turn on the spot
    # and run for a certain number of rotations *of the wheels*
    tank_turn.on_for_rotations(STEERING,
                               SpeedPercent(TURN_SPEED),
                               TURN_ROTATIONS)

We can extract this code into a function that allows us to draw a square whenever we want. By adding an optional side_length parameter we can change the side length as required.

Download the following program to the simulator and run it there.

Can you modify the program to draw a third square with a size somewhere between the size of the first two squares?

%%sim_magic_preloaded -p --pencolor green -a 0

SIDES = 4

# Try to draw a square
STEERING = -100
TURN_ROTATIONS = 1.6
TURN_SPEED = 10

STRAIGHT_SPEED_PC = SpeedPercent(40)
STRAIGHT_ROTATIONS = 6

def draw_square(side=STRAIGHT_ROTATIONS):
    """Draw square of specified side length."""
    for side_number in range(SIDES):
        # Go straight
        # Set the left and right motors in a forward direction
        # and run for the number of rotations specified by: side
        tank_drive.on_for_rotations(STRAIGHT_SPEED_PC,
                                    STRAIGHT_SPEED_PC,
                                    # Use provided side length
                                    side)

        #Turn
        # Set the robot to turn on the spot
        # and run for a certain number of rotations *of the wheels*
        tank_turn.on_for_rotations(STEERING,
                                   SpeedPercent(TURN_SPEED),
                                   TURN_ROTATIONS)
        
        
# Call the function to draw a small size square
draw_square(4)

# And an even smaller square
draw_square(2)

1.4.1 Optional activity

Copy the code used to define the draw_square() function, and modify it so that it takes a second turn parameter that replaces the TURN_ROTATIONS value.

Use the turn parameter to tune how far the robot turns at each corner.

Then see if you can use a for...in range(N) loop to call the square-drawing function several times.

Can you further modify the program so that the side length is increased each time the function is called by the loop?

Share your programs in your Cluster group forum.

1.5 Previewing the simulated robot state from a notebook code cell

As well as viewing the sensor state via the simulator user interface, we can also review it in the notebook itself.

We can create a reference to an object that uses some magic to grab a snapshot of the state of the robot in the default roboSim simulator.

robotState = %sim_robot_state

The %sim_robot_state needs to be run in a cell, before we can check that captured state in another cell.

But once it has run, we can use the robotState variable to preview the snapshot of the state of the robot.

The state data itself is returned as a Python dictionary which we can reference into to view specific data values. For example, the x-coordinate:

robotState.state["x"]

Or how about the penDown state?

robotState.state['penDown']

For a full list of possible values, we can review all the keys associated with the robotState.state dictionary:

robotState.state.keys()

Let’s use some magic to change the pen down state and then take another snapshot of the robot’s state:

%sim_magic --pendown
robotState = %sim_robot_state
robotState.state['penDown']

1.6 Reporting on robot state in the notebook

Having grabbed a snapshot of the robot’s state into the notebook, we can create a function to write reports in the notebook’s own code environment describing the state of the robot.

For example, the robotState.state dictionary includes the following keys:

  • left_light_raw / right_light_raw for the raw RGB values

  • left_light / right_light for the reflected_light_intensity values

  • left_light_pc / right_light_pc for the reflected_light_intensity_pc values

  • left_light_full / right_light_full for the full_reflected_light_intensity values.

We can create a simple function to display this values to make it easier for us to probe the state of the robot:

def report_robot_left_sensor(state):
    """Print a report of the left light sensor values."""
    
    print(f"""
RGB: {state['left_light_raw']}
Reflected light intensity: {state['left_light']}
Reflected light intensity per cent: {state['left_light_pc']}
Full reflected light intensity (%): {state['left_light_full']}
""")

Let’s see how it works:

report_robot_left_sensor(robotState.state)

By defining our own robot state sensor value reporting function, we have created a tool that we can use to quickly preview the state of the robot (which exists in the simulator) in the notebook environment.

This demonstrates one of the ways in which we can use code: not just as a tool for writing programs, such as the code that defines the robot simulator, or as code for controlling our simulated robot within the simulator – nor even as an automation script for setting up the simulator using cell magics – but as a tool for building tools we can use in our computing environments.

To simplify access to a variant of this function, report_light_sensor(state, side) in future notebooks, we can import it from the pre-installed custom nn_tools.sensor_data package and make use of it as follows:

from nn_tools.sensor_data import report_light_sensor

report_light_sensor(robotState.state, 'left')

1.7 Summary

You have seen that a Python function is a named sequence of commands that can be ‘called’ or invoked from anywhere in the main program or from another function. Functions offer four advantages:

  • they allow a piece of program functionality to be ‘encapsulated’ in a clear and detached way

  • they allow the functionality to be used many times from anywhere in the program

  • they simplify the program, making it easier to understand

  • they make programs less prone to error.

Other programming languages have similar features to functions that offer additional benefits. Depending on the language, or the context in which they are defined, these may be known as macros, methods, procedures or subroutines.