BBR Digital Expander Documentation

Odometry and pose#

These calls fail if the gyro cannot be trusted, rather than reporting a pose the board cannot stand behind. See Troubleshooting.

The board can track where your robot is — x, y, and heading — by combining two dead-wheel encoders with the onboard gyro. The integration runs on the board at 1 kHz, and you read the answer in a single I2C transaction. Needs the odometry variant.

Which encoder ports the pods use#

The localizer reads two of the four quadrature encoder channels. You choose which two — there is no fixed pair — but they must be different channels, and both must be assigned or the localizer will not start. Starting it with a port unassigned fails with ERR_LOC_BAD_PARAMS rather than running on a guess.

Rolls when the robot Typically mounted
X pod drives forward or backward along the robot’s fore-aft axis
Y pod strafes left or right across it, perpendicular to X

Whichever channels you plug them into become portX and portY. Channels 0 and 1 are only a convention — the examples use them because they leave 2 and 3 free for other encoders, not because the board cares.

Both pod channels must stay in QUADRATURE mode. A channel switched to PULSE_WIDTH is reading a pulse width, not counting ticks, and cannot feed the localizer. If you are using absolute encoders elsewhere, keep them on the two channels the pods are not using.

Getting the directions right#

The localizer expects forward travel and left travel to both count positive. A pod mounted the other way round counts backwards, and the pose drifts in a way that looks like a ticksPerMm problem but isn’t.

Check it before you trust any pose. Read the two channels directly, push the robot by hand, and watch which way they move:

Push the robot X channel Y channel
forward counts up barely moves
backward counts down barely moves
left (strafe) barely moves counts up
right (strafe) barely moves counts down

If one counts the wrong way, fix it on the board rather than negating numbers in your code — the localizer reads the channels itself, so a sign flip in your program never reaches it:

// The X pod counts backwards, so tell the board to flip that channel.
// Saved to flash for you.
exp.setEncoderDirection(1, BBRDigitalExpander.Direction.REVERSE);

See Direction.

Do not change a pod channel’s invert mask or channel mode while the localizer is running. The board swallows one tick delta rather than integrating the jump, and sets the PORT_CONFLICT flag so you know it happened — but that flag is sticky and the pose has lost that tick. Set direction and mode once, at setup, before starting the localizer.

Setting it up#

Tell the board about your odometry pods once:

BBRDigitalExpander.LocalizerParams p = new BBRDigitalExpander.LocalizerParams();
p.portX = 0;              // encoder channel for the X (forward) pod
p.portY = 1;              // encoder channel for the Y (lateral) pod
p.ticksPerMmX = 13.26;    // measure these, see below
p.ticksPerMmY = 13.26;
p.tcpOffsetXMm = 0;       // tracking point offset from robot centre, mm
p.tcpOffsetYMm = 0;
p.imuScalar = 1.0;        // gyro scale correction
exp.setLocalizerParams(p);

Stored parameters take effect on the next localizer reset, not immediately. Set them first, then start the localizer.

setLocalizerParams() does not save to flash — call saveConfigToFlash() after it, or your pods’ resolution is gone at the next power-on. (setEncoderDirection(), below, does save itself.)

Measuring ticksPerMm: push the robot a couple of metres along one axis, then divide the counts by the distance travelled. Do it for each axis separately. This is the number that decides whether your pose is right, so measure it properly rather than calculating it from the wheel diameter on the datasheet.

Measuring imuScalar: spin the robot ten full turns and compare the reported heading change against 3600°. The ratio is your scalar, and it should land near 1.0.

portX and portY are the channels you picked above. Getting them the wrong way round is not a subtle failure — the robot will think forward is sideways.

Then start it, which zeroes the pose and calibrates the gyro in one step:

exp.resetLocalizerAndCalibrateImu();

if (!exp.waitForLocalizerReady(5000)) {
    telemetry.addLine("Localizer never reached RUNNING — is the robot moving?");
    telemetry.update();
    return;
}

The robot must be still for this. It takes one to two seconds. If it moves, the call returns false instead of a fresh calibration: the pose is still zeroed and the localizer still starts, using the previous gyro bias, so a bump never ends the match.

Reading the pose#

BBRDigitalExpander.Pose2D pose = exp.getPose();
pose.xMm;             // millimetres
pose.yMm;             // millimetres
pose.headingRad;      // radians
pose.headingDeg();    // degrees, for telemetry

getPose() throws unless the localizer is actually RUNNING — you never get a stale or made-up position.

Conventions: millimetres and radians, +X forward, +Y left, counter-clockwise positive. This is the standard FTC field convention, so it lines up with the rest of your code there, and it is the usual robotics convention everywhere else.

The field names say their units — xMm, headingRad — so a unit mix-up has to survive you typing the wrong name. Use headingDeg() for display rather than converting by hand.

Setting the pose#

Zero it, or place the robot at a known point:

exp.setPose(new BBRDigitalExpander.Pose2D(0, 0, 0));
exp.setPose(BBRDigitalExpander.Pose2D.fromDegrees(500, -200, 90));

fromDegrees() exists because everything else in your code is in degrees and converting by hand is an easy place to get a factor of 57.3 wrong.

Setting the pose is how you tell the robot where it started, or correct it mid-run from a known feature — a vision fix, or driving into a wall at a known place.

Checking status#

BBRDigitalExpander.LocalizerState s = exp.readLocalizerRaw();
if (s.status == BBRDigitalExpander.LocalizerStatus.RUNNING) {
    // pose is good
}
s.hasGyroEverSaturated();   // latched: a collision hard enough to lose heading
s.hasPortConflict();        // a pod channel was disturbed mid-run
s.isPoseClipped();          // the pose ran outside ±32.7 m

Worth watching during development. If the localizer drops out of running, you want to know that rather than to keep driving on a pose that stopped updating.

gyroEverSaturated is latched since the last reset. Once it is true, the heading — and everything integrated from it — has lost an unknown amount of turn, and the fix is a fresh fix from outside, not a smaller loop time.

Getting good results#

  • Pod geometry has to be right. The board cannot detect that you measured the wheelbase wrong; it will just accumulate error confidently.
  • Calibrate with the robot still. A bad gyro bias turns into heading drift, and heading drift turns into position error that grows the further you drive.
  • Dead wheels need contact. A pod that skips over a seam loses counts permanently — odometry has no way to notice or recover.
  • Re-zero when you can. If one phase ends at a known position, setting the pose at the start of the next beats inheriting accumulated error.

Worked example#

BBRLocalizerExample covers the whole flow: params, calibration, waiting for RUNNING, live pose, re-zeroing and teleporting. See Example OpModes.