Your first simulated test¶
By the end of this page you have a test that drives a linear slide to a target in simulation, asserts it got there, and leaves an RLOG you can open in AdvantageScope.
The maintained example is
SimLoop/src/test/java/org/horizon36596/simloop/example/SlideReachesTargetExampleTest.java.
It runs with the library's tests. Use the source at your dependency's tag if a snippet here differs
from the version you installed.
The documentation build also checks that the complete Java block below matches that test, apart from its class-level documentation comment. A change to one must update the other.
The five things a simulated test always has¶
Whatever you are testing, the shape is the same:
- A clock.
FakeTimer. Everything sim-side that asks the time asks this one, never the wall. That is what makes two runs of the same scenario produce the same log. - Fake hardware, under your real config names. Register each fake in a
FakeHardwareMapusing the same string your robot configuration uses, and your robot code resolves it without knowing. - A plant behind each fake. The fake is the interface; the plant is the physics. A
FakeMotoron its own reports whatever it was told; aMechanism1DofPlantbehind it makes the slide actually move. - A scenario.
ScenarioRunner.runowns the fixed-timestep loop and the whole logging lifecycle, so your test does not hand-write either. - Assertions against the log, not the objects. Read the run back with
RunResult. The same assertions then work against a log a real robot produced, which is the entire point.
The test¶
package org.horizon36596.simloop.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.horizon36596.simloop.config.DrivetrainSimConfig;
import org.horizon36596.simloop.config.MechanismSimConfig;
import org.horizon36596.simloop.config.SimRobotConfig;
import org.horizon36596.simloop.fakehardware.FakeHardwareMap;
import org.horizon36596.simloop.fakehardware.FakeMotor;
import org.horizon36596.simloop.loop.RunResult;
import org.horizon36596.simloop.plant.Mechanism1DofPlant;
import org.horizon36596.simloop.sim.FakeTimer;
import org.horizon36596.simloop.sim.ScenarioRunner;
import org.junit.jupiter.api.Test;
import org.psilynx.psikit.core.Logger;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
class SlideReachesTargetExampleTest {
/** Fixed sim time step, in seconds. 0.02 s is 50 Hz, the rate an FTC OpMode loop is written for. */
private static final double DELTA_TIME_SECONDS = 0.02;
/** How long the scenario runs, in ticks. 150 ticks at 0.02 s is three seconds of robot time. */
private static final int TICKS = 150;
/** Where the slide is told to go, in inches. */
private static final double TARGET_INCHES = 12.0;
/**
* The log key the slide's position is READ BACK under, which is not the key it was written under.
* PsiKit files everything {@code Logger.recordOutput} writes beneath {@code RealOutputs/}, so a run
* that logged {@code "Slide/positionInches"} is queried as {@code "RealOutputs/Slide/positionInches"}.
* Getting this wrong is the most common first-run surprise; it throws
* {@code RunResult.MetricNotFoundException} rather than returning zero, on purpose.
*/
private static final String SLIDE_POSITION = "RealOutputs/Slide/positionInches";
/**
* The slide's physical numbers. On a real robot these are measured; here they are plausible values
* for a two-stage linear slide on a 435 rpm motor.
*/
private static final MechanismSimConfig SLIDE = new MechanismSimConfig() {
@Override public double timeConstant() { return 0.12; } // seconds
@Override public double maxSpeed() { return 30.0; } // inches per second
@Override public double minPosition() { return 0.0; } // inches
@Override public double maxPosition() { return 24.0; } // inches
@Override public double gravityHoldPowerFraction() { return 0.0; } // unitless; a horizontal slide
};
/**
* SimLoop needs a robot-level config even when the scenario drives no drivetrain, because a run is
* named after the robot it ran on. A season repo implements this once and reuses it.
*/
private static final SimRobotConfig ROBOT = new SimRobotConfig() {
@Override public String[] driveMotorNames() { return new String[] {"fl", "fr", "bl", "br"}; }
@Override public String odometryName() { return "odo"; }
@Override public DrivetrainSimConfig drivetrain() {
return new DrivetrainSimConfig() {
@Override public double trackWidth() { return 12.0; } // inches
@Override public double wheelBase() { return 12.0; } // inches
@Override public double wheelRadius() { return 2.0; } // inches
@Override public double ticksPerInch() { return 100.0; } // ticks per inch
@Override public double maxVelocityTicksPerSecond() { return 2000.0; } // ticks per second
@Override public double maxAccel() { return 8.0; } // 1/seconds
@Override public double fieldHalfWidth() { return 72.0; } // inches
@Override public double fieldHalfHeight() { return 72.0; } // inches
};
}
};
@Test
void theSlideReachesItsTargetAndStaysThere() throws IOException {
// 1. The clock. Every sim-side thing that asks the time asks this, never the wall (domain R5).
FakeTimer timer = new FakeTimer();
// 2. The hardware your robot code will resolve, under the names your robot config uses.
FakeMotor slideMotor = new FakeMotor(2000.0, 8.0);
FakeHardwareMap hardwareMap = new FakeHardwareMap();
hardwareMap.register("slide", slideMotor);
// 3. The physics behind that motor. The plant reads the motor's commanded power and moves.
Mechanism1DofPlant slidePlant = new Mechanism1DofPlant(slideMotor, SLIDE);
// 4. Somewhere to put the log. build/ is gitignored, which is where a run belongs.
Path rlogPath = Paths.get("build", "sim", "slide-reaches-target.rlog");
ScenarioRunner.run("SlideExample", ROBOT, timer, TICKS, DELTA_TIME_SECONDS, rlogPath, deltaTime -> {
// This is the part that, on a real robot, is your subsystem's periodic(). A proportional
// controller is enough to show the shape; yours will be whatever you actually run.
double error = TARGET_INCHES - slidePlant.getPosition();
double power = Math.max(-1.0, Math.min(1.0, error * 0.25));
slideMotor.setPower(power);
// The plant advances on the same tick the robot code just commanded.
slidePlant.update(deltaTime);
// Tell the motor how fast its shaft is REALLY turning. Without this line the motor keeps
// modelling a happily spinning shaft even while the slide sits on an end stop, so anything
// that reads motor current - a stall detector, a re-zero routine - reads a number the
// mechanism does not agree with. A drivetrain motor never needs it; a mechanism motor always
// does.
slideMotor.setMeasuredShaftSpeedFraction(slidePlant.getVelocityFractionOfMaxSpeed());
hardwareMap.updateAll(deltaTime);
// Log what you want to assert on, and what you want to look at in AdvantageScope.
Logger.recordOutput("Slide/positionInches", slidePlant.getPosition());
Logger.recordOutput("Slide/commandedPower", power);
});
// 5. Read the run back out of the log and score it. Nothing here looks at the plant object -
// the log is the record, which is what makes the same assertions work on a real robot's log.
RunResult run = RunResult.fromRlog("SlideExample", rlogPath, TICKS);
assertEquals(TARGET_INCHES, run.finalValue(SLIDE_POSITION), 0.25,
"the slide should finish within a quarter inch of its target");
// Overshoot, not the end stop. Asserting the slide stayed inside minPosition..maxPosition would
// pass no matter what the controller did, because the plant clamps to those every tick - it is
// the model's guarantee, not the controller's achievement. Overshoot is the thing this gain can
// actually get wrong: this run peaks around 13 inches, so 2 inches of headroom catches a change
// that makes it ring without failing on ordinary tuning noise.
assertTrue(run.maxValue(SLIDE_POSITION) <= TARGET_INCHES + 2.0,
"the slide overshot its target by more than two inches: "
+ run.maxValue(SLIDE_POSITION));
}
}
Check the log key, motor feedback and tick order¶
The key you log is not the key you read. PsiKit files everything Logger.recordOutput writes beneath
RealOutputs/. A run that logged "Slide/positionInches" is queried as
"RealOutputs/Slide/positionInches". Get it wrong and RunResult throws MetricNotFoundException rather
than handing you a zero — a silent zero would be a passing test that measured nothing.
A mechanism motor has to be told how fast it is really turning. FakeMotor models its own unloaded
speed and knows nothing about what is bolted to it, so a slide parked on its end stop would still report a
spinning shaft and near-zero current — and current is what a stall detector and a re-zero routine read.
The setMeasuredShaftSpeedFraction line above is what fixes that, and it is needed every tick. A
drivetrain motor never calls it.
Update the plant on the same tick the robot code commanded it. The order inside the scenario body is:
read state, decide, command the fake, then plant.update(deltaTime). Commanding after updating shifts
every response one tick late, which looks like a tuning problem and is not.
Why there is no assertTrue(run.isComplete())
ScenarioRunner.run already throws if the log does not decode to one frame per tick — that is one of
the four things it refuses to hand back a log for. Asserting it afterwards would always pass, which is
worse than not asserting it: it reads like a check and is not one.
Running it¶
Put the test in your own test sourceset — TeamCode/src/test/java/... in a standard FTC project —
and run your module's test task:
The scenario advances three seconds of simulated time. Build and test startup time depend on your machine and whether Gradle has cached its dependencies.
The complete example also lives in SimLoop's test sources. Compare with the version at your pinned tag if you adapt the code.
Opening the log¶
The test writes build/sim/slide-reaches-target.rlog relative to the test task's working directory.
For a standard FTC TeamCode test, look under TeamCode/build/sim/. Open the file in
AdvantageScope: File - Open, pick the file,
then drag RealOutputs/Slide/positionInches onto a line graph. You should see it rise and flatten at 12
inches.
A recorded run of this example: 150 ticks at 0.02 s, using the example's placeholder plant. The final position is 12.000 in when rounded to three decimals. This is simulated data, not a robot measurement. Download the plotted values.
build/ is deleted by ./gradlew clean
The log is a build output, not a document. If it is gone, re-run the test - that is the command that regenerates it.
Where to go next¶
- What it does not do - read this before you plan anything around SimLoop.
- The
plantpackage - what the model behind your mechanism actually is, and what it is not. - Troubleshooting - the failures that look like a bug in your robot code and are not.