Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add coroutine command support #12

Draft
wants to merge 30 commits into
base: main
Choose a base branch
from

Conversation

TheTripleV
Copy link
Member

@TheTripleV TheTripleV commented Apr 22, 2022

Still todo

  • Add support for runsWhenDisabled in the constructor
  • Accept coroutines in helper functions (.andThen, etc.) and Subsystem.setDefaultCommand? These currently must be manually wrapped in CoroutineCommands
  • Tests
  • Docs

Overview

Add support for coroutines into commands2. These coroutines are compatible with commands. Coroutines can be converted to Commands and Commands can be converted to coroutines.

Note: "Coroutine" in this PR and impl act as coroutines for the purposes of FRC but are actually generators. This PR does not add support for any async/await. Coroutines have been implemented as generators so they can run synchronously with robot code and can be managed by the CommandScheduler loop (instead of asyncio).

Usage

Buttons/Triggers

Buttons and Triggers currently accept Commands and functions (Callable[[], None]). This has been expanded to accept generators and generator functions.

Buttons have been moved to a Python implementation and Trigger has been partially moved (the exposed Trigger is Python that encapsulates the c++ Trigger) to facilitate this. This is because most of Trigger's methods have to have multiple return types to support both decorator and non-decorator forms.

Generator

b: Button

def generator_command():
	while True:
		print(i)
		yield

g = generator_command()

b.whenPressed(g)

Warning: While this is supported, it is not recommended. Python does not allow for generators to be copied. The workaround, tee can potentially use infinite memory. Because of this, coroutines bound this way can only be used once. The above snippet would only work the first time the button is pressed.

Generator Function

b: Button

def generator_command():
	while True:
		print(i)
		yield

b.whenPressed(generator_command)

This is the recommended way to pass in generators in the non-decorator style. Using a function with no params (Callable[[], Generator[None, None, None]) that builds the generator. The internally used CoroutineCommand saves this function and rebuilds the generator each time .initialize() is called.

Buttons/Trigger as decorators

All Callables accepted by buttons and triggers can also be passed in by using it as a decorator. This style is how flask, fastapi, and Swift (Trailing Closure Syntax) do things.

Functions

b: Button

# Ex 1
@b.whenPressed
def _():
	print(1)

# Ex 2
@b.whenPressed()
def _():
	print(2)

# Ex 3
@b.whenPressed(interruptible=False)
def _():
	print(3)

# Ex 4
@b.whenPressed(requirements=[robot.drivetrain])
def _():
	print(4)

Generator Functions

All 4 example arguments as above work but only the "Ex 1" form is shown.

b: Button

@b.whenPressed
def _():
	while True:
		print(i)
		yield

Simple Example in 2022 Robot Code
Complex Example in 2022 Robot Code

Composing Coroutines

In the current command framework, commands are composed using extra classes (ParallelCommandGroup, SequentialCommandGroup, etc.) or using helper functions (.andThen(...), etc.).
Coroutines aren't natively supported by these constructors (yet?) and must be wrapped in CoroutineCommand to enable this.

But, coroutines can be composed within themselves using yield from.

def a():
	for i in range(10):
		yield

def b():
	print(1)
	yield
	print(2)
	yield from a() # this runs a until it's complete (each yield in a() still applies of course)

Furthermore, since coroutines are generators with no input, they can be used as iterables.

yield from a()

is equivalent to

for _ in a():
	yield

for the purposes of this framework.

Very Complex Example in 2022 Robot Code (Untested)

So, to run coroutines in parallel and move on when any one command finishes:

def c():
	for _ in zip(a(), b()):
		yield

Converting Coroutines to Commands

commandify

The @commandify decorator lets commands be written as coroutines and be exposed as commands currently are.

@commandify
def MyCommand(i, j):
	print(i)
	yield
	print(j)

The signature of MyCommand is now (i, j) -> CoroutineCommand

command: CoroutineCommand = MyCommand(1,2)

Requirements can be passed into commandify as

@commandify(requirements = [robot.climber])

Note that the access time of requirements has changed.
In the new style (above), requirements are saved when the file is parsed.
In the old style, requirements are saved when the command is constructed:

class C(CommandBase):
	def __init__(self):
		self.addRequirements(...)

Because of this, commands with requirements created using commandify must be defined in a scope where the robot object has already been created (in the subsystem object for example).

CoroutineCommand

The CoroutineCommand class's constructor can be used to convert coroutines to commands. This way, coroutine functions defined in code stay as coroutine functions can be converted to commands as needed.

def coro_no_params():
	print(1)
	yield

def coro_params(i, j):
	print(i)
	yield
	print(j)

# Ex 1
cmd = CoroutineCommand(coro_no_params())
# Not Recommended. A generator has been passed. This command cannot be reused.

# Ex 2
cmd = CoroutineCommand(coro_no_params)
# Recommended. A generator function has been passed. This command can be reused.

# Ex 3
cmd = CoroutineCommand(coro_params(1, 2))
# Not recommended. A generator has been passed. This command cannot be reused.

# Ex 4
cmd = CoroutineCommand(lambda: coro_params(1,2))
# Recommended. A generator function has been passed. This command can be reused.

Converting Commands to Coroutines

All Command and it's subclasses are now iterable. Calling command_obj.__iter__() returns an iterable that will only exhaust when the command's isFinished returns True.
A coroutine is created with command_obj.__iter__().
A coroutine function is created with command_obj.__iter__.

Using Commands in coroutines

Manual calls

Command's initialize, execute, and isFinished can be manually called.

def a():
	cmd = MyCommand()
	cmd.initialize()
	while not cmd.isFinished():
		cmd.execute()
		yield

Yield from

Via the __iter__ added to Command, Commands can be yielded from in coroutines.

def a():
	cmd = MyCommand()
	yield from cmd

# OR

def a():
	yield from MyCommand() # todo: make sure the command isn't destroyed by c++ before it finishes.

Copy link
Member

@virtuald virtuald left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me so far, seems like a nice capability. Definitely would want docs/tests (most of the docs could just be a page created from what you have in this PR).


class JoystickButton(Button):
def __init__(self, joystick: Joystick, button: int) -> None:
super().__init__(lambda: joystick.getRawButton(button))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what error message you get when something that isn't an integer is passed in...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It constructs fine but fails the first time .get is called:

py", line 21, in <lambda>
    super().__init__(lambda: joystick.getRawButton(button))
TypeError: getRawButton(): incompatible function arguments. The following argument types are supported:
    1. (self: wpilib.interfaces._interfaces.GenericHID, button: int) -> bool

Invoked with: <XboxController 0>, 'qwe'

which isn't an ideal error message. I'll check the type in the constructor.

commands2/button/networkbutton.py Outdated Show resolved Hide resolved
commands2/src/Command.cpp.inl Outdated Show resolved Hide resolved
commands2/trigger.py Show resolved Hide resolved
commands2/trigger.py Show resolved Hide resolved
commands2/trigger.py Outdated Show resolved Hide resolved
commands2/button/button.py Show resolved Hide resolved
commands2/button/networkbutton.py Outdated Show resolved Hide resolved
commands2/coroutinecommand.py Outdated Show resolved Hide resolved
from typing import Any, Callable, Generator, List, Union, Optional, overload
from ._impl import CommandBase, Subsystem
import inspect
from typing_extensions import TypeGuard
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: Figure out the minimum version of typing_extensions to depend on

@TheTripleV
Copy link
Member Author

Todo: Add support for the new event loop stuff. This can look pretty similar to the existing stuff, where lambdas are avoided in preference of decorators.

The Java example

BooleanEvent atTargetVelocity =
    new BooleanEvent(m_loop, m_controller::atSetpoint)
        // debounce for more stability
        .debounce(0.2);

// if we're at the target velocity, kick the ball into the shooter wheel
atTargetVelocity.ifHigh(() -> m_kicker.set(0.7));

is clunky in Python because builder syntax isn't supported and the condition must be a one-line lambda or a function (same issue that this whole pr is about).

The first statement would have to be written as

atTargetVelocity = BooleanEvent(m_loop, m_controller.atSetpoint).debounce(0.2)

or

atTargetVelocity = (
    BooleanEvent(m_loop,m_controller.atSetpoint)
        .debounce(0.2)
)

The event could be defined with multiple decorators

@event.debounce(0.2)
@event(m_loop)
def atVelocityTarget():
    return m_controller.atSetpoint()

or by punting all builders into the constructor (this is how the existing syntax does it and is way better for intellisense)

@event(m_loop, debounce=0.2)
def atVelocityTarget():
    return m_controller.atSetpoint()

This event can then be used as a decorator to define a callback as

@atVelocityTarget.isHigh()
def _():
    m_kicker.set(0.7)

or in the case of rising/falling

@atTargetVelocity.rising().isHigh()
def _():
    m_kicker.set(0.7)

Food for thought:
Since there isn't a isLow(), isHigh() can be made implicit when an event is used as a decorator

@atVelocityTarget
def _():
    m_kicker.set(0.7)

@atTargetVelocity.rising()
def _():
    m_kicker.set(0.7)

Food for thought, the sequel:
In the wpilib version, rising() can be used when the event is built or when the event it used. That's more difficult here. Because of the order of operations, the decorator order would be reversed depending on whether they are used at event construction time or event usage time.

I think the solution might be to leave these as builders that are forced to be one lined as

@event(m_loop).debounce(0.2)
def atVelocityTarget():
    return m_controller.atSetpoint()

@virtuald virtuald mentioned this pull request May 25, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants