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

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.arm.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.

.until(...) returns a builder, not a Command, the same way Command.sequence(...) did. .named("...") closes it, and leaving it off fails the build.

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() is a hold. It re-sends its position 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));

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

raiseArm is a Command like any other now, so it can be bound to a button or dropped into a routine. It ends itself either way.

What a timeout proves

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

One button, both mechanisms

Raise the arm, wait for it to really arrive, then spin the flywheel while the arm goes on holding. That is not a list of steps. A sequence drives the mechanisms it names one at a time, so the arm cannot keep holding while the flywheel spins.

The shape with two timelines is a coroutine. Its body is ordinary Java, read top to bottom, and it can pause partway through and carry on from the same line.

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());
}

Three verbs, and the middle one is this lesson's. fork starts a command and keeps reading, so the arm hold runs underneath everything after it. waitUntil stops until isAtTarget() comes back true. await runs a command and stops until it finishes.

MyTeleop needs robot as a field: private final Robot robot;, assigned in the constructor. Import org.wpilib.command3.Command and org.wpilib.command3.Coroutine.

Command.noRequirements claims no mechanism of its own, and does not need to: each forked command claims its own, for only as long as it runs. One mechanism, write a composition. Two that have to overlap, write a coroutine.

Conditions that never come true

A condition that cannot go true is as bad as a bare hold. The sequence sits on that step, nothing throws, nothing logs, and the arm 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 mechanism crosses the target for one loop on its way past. The step ends while it is still moving.

The timeout covers the first two and cannot help with the third. For a mechanism 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 raiseArm 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 arm's tolerance to Degrees.of(0.001) and press it again.
  4. Leave the tolerance broken, drop .withTimeout(...), and press it once more.
  5. Put the tolerance back, add the Y binding, and hold Y. The arm goes up, and the flywheel starts only once the arm is there.
Check

You should see

  • The arm reaching its angle 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 arm pushing 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.