Seamonsters-2605.github.io

Generators in the Seamonsters Library

Last year we added a framework to our Seamonsters Python Library for building robots using Python Generators. The goal was to replace RobotPy’s Command framework, which we used in 2017. Generator-based autonomous code looks cleaner and more concise than equivalent Command-based code.

But what is a generator??

Generators are a way to build iterators, to be used in a “for” loop. Instead of having to build a class which implements the iterator pattern, a generator can be expressed as a single function. Generators use the yield command to produce values for iteration. For example:

def my_generator():
    yield 1
    yield 2
    yield 3
    yield 4

for x in my_generator():
    print(x)
# Output:
#   1
#   2
#   3
#   4

The yield command “pauses” the function and gives control over to the for loop. It saves the location in the function and all of the variable values, and when one loop iteration has completed, it returns back to where it left off.

In this way generators act similar to coroutines—functions can suspend at their current location and resume later.

Building a robot using generators

The seamonsters library has a class called GeneratorBot. It is modelled after IterativeRobot, but instead of having, for example, teleopInit and teleopPeriodic functions, it has a teleop generator. This generator completes an iteration 50 times per second, locked to the Driver Station update interval (just like teleopPeriodic). This means that the yield command is equivalent to saying “pause for 1/50th of a second.”

Like Commands, this is a way to avoid using complex state machines to make complex sequences. Generators have some implicit state in that they save their location and variable values when they yield.

Comparing commands to generators

Commands in RobotPy take the form of classes, which look like this:

class MyCommand(wpilib.command.Command):

    def __init__(self):
        super().__init__()
    
    def initialize(self):
        # immediately before command runs
        pass

    def execute(self):
        # 50 times per second while the command runs
        pass

    def isFinished(self):
        # is the command finished?
        return True

    def interrupted(self):
        # if the command was interrupted before it finished
        pass

    def end(self):
        # after the command completes
        pass

In a similar way to building a library of commands for different actions of your robot, you can build a library of generator functions. Generators have similar capabilities to commands but express them in a more concise way. Here is what the command above would look like as a generator:

def my_generator():
    initialize()
    while not isFinished():
        execute()
        yield
    end()

Or a more advanced version, which incorporates the interrupted() function:

def my_generator():
    initialize()
    try:
        while not isFinished():
            execute()
            yield
    except GeneratorExit: # when the loop breaks or generator is otherwise interrupted
        interrupted()
    finally:
        end()

Many of the patterns that the Command framework makes simple and easy, are just as simple and easy with generators. For example, running commands in sequence.

With commands:

group = CommandGroup()
group.addSequential(Command1())
group.addSequential(Command2())

With generators:

def generator_sequence():
    yield from generator1()
    yield from generator2()

yield from is a shortcut that is equivalent to saying:

for _ in generator1():
    yield

This is more intuitive than the Command example. Instead of building a group object and adding commands to it at the start of autonomous, you’re just calling each of them in order, with a function that seems to stay running throughout the autonomous period. You can even insert extra code in between. This is something that is very simple with generators but would require you to build entire new Command subclasses:

def generator_sequence():
    yield from generator1()
    print("I just finished generator1!!")
    self.output.set(True)
    yield from generator2()

The seamonsters library includes several functions to replicate the utility of commands. For example, you can run generators in parallel:

def generators_in_parallel():
    yield from sea.parallel(generator1(), generator2())

For each iteration of generators_in_parallel, one iteration each will be completed of generator1 and generator2. sea.parallel will continue until all of the given generators have completed.

Here are some others:

Usually our robot generators don’t yield values (they just yield for timing). But yielding values each iteration can be a useful property that commands lack. We had a generator function last year which would yield True or False if a vision target was visible. We could call it with sea.untilTrue(generator), which would stop it once it yielded True. Or we could call it with sea.ensureTrue(generator, count), which would stop it only if it yielded True a certain number of times in a row, to prevent noise.

Generators can also return values after they complete. So the equivalent to a ConditionalCommand would look like this:

if yield from conditional_generator():
    yield from generator1()
else:
    yield from generator2()

Conclusion

Generators are neat, and they worked well for us last year! Though there was a learning curve as a different paradigm for programming, they ultimately made it easier for new programmers to see what was happening in autonomous sequences. Here are some examples of how we used generators last year:

Finally, here is the code for our implementation of generators for robots: