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);
// The X pod counts backwards, so tell the board to flip that channel.
// Saved to flash for you.
expander.setEncoderDirection(1, BBREncoderDirection::Reverse);
# The X pod counts backwards, so tell the board to flip that channel.
# Saved to flash for you.
expander.set_encoder_direction(1, EncoderDirection.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);
BBRLocalizerParams p;
p.portX = 0; // encoder channel for the X (forward) pod
p.portY = 1; // encoder channel for the Y (lateral) pod
p.ticksPerMmX = 13.26f; // measure these, see below
p.ticksPerMmY = 13.26f;
p.tcpOffsetXMm = 0.0f; // tracking point offset from robot centre, mm
p.tcpOffsetYMm = 0.0f;
p.imuScalar = 1.0f; // gyro scale correction
expander.setLocalizerParams(p);
expander.set_localizer_params(LocalizerParams(
port_x=0, # encoder channel for the X (forward) pod
port_y=1, # encoder channel for the Y (lateral) pod
ticks_per_mm_x=13.26, # measure these, see below
ticks_per_mm_y=13.26,
tcp_offset_x_mm=0.0, # tracking point offset from robot centre, mm
tcp_offset_y_mm=0.0,
imu_scalar=1.0, # gyro scale correction
))
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;
}
expander.resetLocalizerAndCalibrateImu();
if (!expander.waitForLocalizerReady(5000)) {
Serial.print(F("Localizer never became ready: "));
Serial.println(expander.lastErrorText());
}
expander.reset_localizer_and_calibrate_imu()
try:
expander.wait_for_localizer_ready(5000)
except BBRError as exc:
print(f"Localizer never became ready: {exc}")
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.
BBRPose pose;
if (expander.getPose(pose)) {
pose.xMm; // millimetres
pose.yMm; // millimetres
pose.headingRad; // radians
pose.headingDeg(); // degrees, for printing
}
getPose() returns false unless the localizer is actually running — you never get a stale or made-up position. For velocities as well as position, read the whole block with readLocalizer().
pose = expander.get_pose()
pose.x_mm # millimetres
pose.y_mm # millimetres
pose.heading_rad # radians
pose.heading_deg # degrees, for printing
get_pose() raises unless the localizer is actually running — you never get a stale or made-up position. For velocities as well as position, read the whole block with read_localizer().
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.
expander.setPose(0.0f, 0.0f, 0.0f);
expander.setPose(500.0f, -200.0f, radians(90.0f));
The heading is in radians and is normalized into [−π, π) for you, so radians(90) from a degree-based calculation is fine to pass straight in.
expander.set_pose(0.0, 0.0, 0.0)
expander.set_pose(500.0, -200.0, math.radians(90.0))
The heading is in radians and is normalized into [−π, π) for you, so math.radians(90) from a degree-based calculation is fine to pass straight in.
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
BBRLocalizerState s;
if (expander.readLocalizerRaw(s) && s.status == BBRLocalizerStatus::Running) {
// pose is good
}
s.gyroEverSaturated(); // latched: a collision hard enough to lose heading
s.portConflict(); // a pod channel was disturbed mid-run
s.poseClipped(); // the pose ran outside ±32.7 m
s = expander.read_localizer_raw()
if s.status == LocalizerStatus.RUNNING:
... # pose is good
s.gyro_ever_saturated # latched: a collision hard enough to lose heading
s.port_conflict # a pod channel was disturbed mid-run
s.pose_clipped # 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.
Localizer covers the whole flow: params, calibration, waiting for ready, live pose, re-zeroing and teleporting. See Example sketches.
localizer.py covers the whole flow: params, calibration, waiting for ready and live pose. See Example scripts.