Gray Matter
WorkshopFinish Conditions
Rough draft: nobody has reviewed this lesson yet, and it may not be how things are done this season.
LESSON 19

Finish Conditions

Command Composition ended every step with a stopwatch, and that number was a guess. This lesson ends a step when a sensor reports the mechanism arrived. The stopwatch stays on as a backstop.

Branchmech-4-ReadingState15 minutes
You’ll need
  • Command.sequence and .withTimeout(...), from Command Composition.
  • An arm position hold, with gains that reach the angle it asks for.
  • Lambdas, from Java Basics.
  • The simulator running, from Hardware Simulation.

What mechanism are you working on?

The lesson below is written for the one you pick. Switch back any time to read it for the other.

Timeouts and conditions

.until(...) wraps a command and ends it on the first loop a condition comes back true. That condition is a BooleanSupplier: a small piece of code that answers true or false when it is asked. The scheduler asks about fifty times a second.

Hand it a lambda. () -> robot.armflywheel.isAtTarget() is a question the scheduler can ask on every loop. Drop the () -> and the method runs on the spot, passing one frozen answer. The build stops on boolean cannot be converted to BooleanSupplier.

Like Command.sequence(...), .until(...) returns a builder, and .named("...") closes it.

The arrival question

The mechanism owns the comparison. Its units, its target and its tolerance already live in that file. Put the arithmetic there too, and every call site gets one readable question.

Arm.java: the arrival check
private final Angle tolerance = Degrees.of(1.0);
 
/** Where the arm is now, straight off the CANcoder. */
public Angle getPosition() {
return encoder.getPosition().getValue();
}
 
/** Where the last position request asked it to go. */
public Angle getTargetPosition() {
return positionOut.getPositionMeasure();
}
 
/** True when the arm has reached its target angle. */
public boolean isAtTarget() {
return getPosition().isNear(getTargetPosition(), tolerance);
}
Flywheel.java: the arrival check
private final AngularVelocity tolerance = RotationsPerSecond.of(0.5);
 
/** How fast the wheel is turning now, straight off the motor. */
public AngularVelocity getVelocity() {
return motor.getVelocity().getValue();
}
 
/** The speed the last velocity request asked for. */
public AngularVelocity getTargetVelocity() {
return velocityOut.getVelocityMeasure();
}
 
/** True when the flywheel has reached its target speed. */
public boolean isAtTarget() {
return getVelocity().isNear(getTargetVelocity(), tolerance);
}

getPosition() reads the CANcoder. getTargetPosition() asks the request object where it was last told to go. Both return an Angle, so nothing here can mix up rotations and degrees. isNear is true when the two are within tolerance, and one degree is the arm's.

getVelocity() reads the motor. getTargetVelocity() asks the request object what speed it was last told to hold. Both return an AngularVelocity, so nothing here can mix up rotations a second and RPM. isNear is true when the two are within tolerance, and half a rotation a second is the flywheel's.

NO EXACT EQUALITY

Sensors jitter

Never wait for positionvelocity == target. Ask for 0.25 rotations and you read 0.2497, then 0.2503.Ask for 75 rotations a second and you read 74.98, then 75.03. An exact comparison is false forever, so a step waiting on one never ends.

Both endings on one step

robot.arm.vertical()robot.flywheel.runFast() is a hold. It re-sends its request every loop and never finishes, which suits a held button and is useless in a list. One call site turns it into a step.

A condition to finish on, a timeout to give up on
import static org.wpilib.units.Units.Seconds;
 
Command raiseArm =
robot.arm.vertical()
.until(() -> robot.arm.isAtTarget())
.named("vertical until at target")
.withTimeout(Seconds.of(2.0));
A condition to finish on, a timeout to give up on
import static org.wpilib.units.Units.Seconds;
 
Command spinUp =
robot.flywheel.runFast()
.until(() -> robot.flywheel.isAtTarget())
.named("runFast until at target")
.withTimeout(Seconds.of(2.0));

The hold itself is untouched. The condition is the ending you want; the timeout is the ending you get when a sensor dies or the armflywheel jams. It goes after .named(...), because .withTimeout(...) is a method on Command, not on the builder.

What a timeout proves

That the waiting is over, and nothing else. If the next step assumes the armflywheel arrived, ask isAtTarget() again before running it.

One button, both mechanisms

Raise the arm, wait for it to arrive, then spin the flywheel while the arm keeps holding. A sequence cannot do that, because each member has to finish before the next starts, so the arm's hold would end first.

The shape for it is a coroutine. Its body is ordinary Java, read top to bottom, and it can pause on a line and carry on from there. This is a first look; Coroutines, next, takes it apart.

  1. Give MyTeleop a robot field. The routine body is a method of its own, outside the constructor, and the constructor's robot parameter does not reach it. Add private final Robot robot; beside driver, and make this.robot = robot; the first line of the constructor.
  2. Import org.wpilib.command3.Command and org.wpilib.command3.Coroutine.
  3. Add the Y binding to the constructor and the method below it to the class.
MyTeleop.java: the binding, and the body it calls
// Y: raise the arm, then spin the flywheel once it is really there.
driver
.y()
.whileTrue(
Command.noRequirements(coroutine -> spinUpWhenReady(coroutine))
.named("Spin Up When Ready (hold)"))
.whileFalse(robot.flywheel.stop());
 
// ... and, further down the class:
 
private void spinUpWhenReady(Coroutine coroutine) {
// fork, not await: vertical() is a hold and never finishes.
coroutine.fork(robot.arm.vertical());
 
coroutine.waitUntil(() -> robot.arm.isAtTarget());
 
// runFast is a hold too, so this never returns: releasing Y cancels the whole routine.
coroutine.await(robot.flywheel.runFast());
}

Read it as three lines. fork starts the arm hold and moves on while it keeps running. waitUntil pauses until isAtTarget() comes back true, the same question .until(...) asks. await starts the flywheel and pauses until it finishes. A hold never finishes, so the routine runs until you let go.

Command.noRequirements claims no mechanism itself. Each forked command claims its own, for only as long as it runs.

Conditions that never come true

A condition that cannot go true is as bad as a bare hold. The step never ends, nothing throws, nothing logs, and the armflywheel keeps pushing. Fifteen seconds of autonomous go on step one.

Never true
Tolerance too tight
The arm settles half a degree outside the band and stops there. The plot looks fine. The routine does not move.
Never changes
A dead sensor
A CANcoder off the bus reports one value forever, so isAtTarget() gives the same answer every loop whatever the arm does.
True too early
Passing through
A fast arm crosses the target for one loop on its way past. The step ends while it is still moving.
Never true
Tolerance too tight
The wheel settles at 74.3 rotations a second, outside the band, and stays there. The routine does not move.
Never changes
A dead sensor
A motor off the bus reports one speed forever, so isAtTarget() gives the same answer every loop whatever the wheel does.
True too early
Passing through
A wheel that overshoots crosses 75 for one loop on the way up. The step ends before the speed has settled.

The timeout covers the first two and cannot help with the third. For a armflywheel that overshoots, require the reading to stay inside tolerance for several loops in a row. Keep that behind the same isAtTarget(), so no call site changes.

Check your work

Run it in the simulator, then break it on purpose. You are done when you can tell the two endings apart without watching a clock.

  1. Add isAtTarget() to Arm and Flywheel, then bind raiseArmspinUp to a button with onTrue in your MyTeleop. It is a step now, so it ends itself.
  2. Press it once and time how long the step takes to end.
  3. Change the armflywheel's tolerance to Degrees.of(0.001)RotationsPerSecond.of(0.001) and press it again.
  4. Leave the tolerance broken, drop .withTimeout(...), and press it once more.
  5. Put the tolerance back and hold Y. The arm goes up, and the flywheel starts only once the arm is there.
Check

You should see

  • The armflywheel reaching its target and the step ending well under two seconds.
  • At 0.001, the step running the full two seconds every time.
  • With the timeout gone as well, the step never ending until you disable.
  • On Y, the flywheel waiting out the arm's travel, then spinning up with the arm still holding.

Write down the tolerance you settled on and how long the step took. Double that time for a backstop that will not fire on a good run. Coroutines asks for that number.

Check yourself

Why is the Y button a coroutine instead of Command.sequence(robot.arm.vertical().until(...), robot.flywheel.runFast())?

Why does isAtTarget() compare against a tolerance instead of checking whether the position equals the target?

What does () -> robot.arm.isAtTarget() hand to .until(...), and why does a bare robot.arm.isAtTarget() not work in the same place?

robot.arm.vertical().until(() -> robot.arm.isAtTarget()) on its own will not compile. What is missing?

A step ends after exactly the 2.0 seconds its .withTimeout(...) allowed. What do you know about the arm?

The arm settles a fraction outside tolerance, so the routine sits on that step for the rest of the match. What keeps one bad step from costing the whole autonomous period?

Pick an answer for each.