Localization and Resync

Odometry accumulates drift over time. After driving 2+ meters, small encoder and IMU errors compound into position errors that can derail mission accuracy. The localization system maintains a parallel world-pose estimate using a particle filter, and resync steps let you inject absolute position observations at known field landmarks to correct accumulated drift in real time.

Concept: Why a Particle Filter?

Raw odometry gives one best-guess pose. A particle filter maintains many candidate poses (particles) simultaneously, each weighted by how well it matches available observations. When a resync observation arrives (wall contact, line detection), particles near the expected landmark gain weight; particles far away lose weight. Resampling then collapses the cloud toward the high-weight region.

The result is a world-pose estimate that is more robust to single large errors than correcting odometry directly — a single noisy measurement doesn’t snap the estimate to a wrong value; it merely shifts the cloud distribution.

graph LR
    subgraph "Resync sources"
        SP["resync_at_start_pose\n(initial anchor)"]
        WL["align_to_wall_resync\n(wall contact)"]
        LN["find_line_resync\n(IR line detection)"]
    end
    subgraph "Localization filter"
        PF["Particle filter\n(128 particles, 100 Hz)"]
        POSE["robot.localization.get_pose()\n(weighted mean)"]
    end
    ODO["Odometry (predict)"]

    ODO -->|"per-tick delta"| PF
    SP -->|"sigma-weighted observation"| PF
    WL -->|"sigma-weighted observation"| PF
    LN -->|"sigma-weighted observation"| PF
    PF --> POSE

    style ODO fill:#42A5F5,color:#fff
    style SP fill:#FF7043,color:#fff
    style WL fill:#FF7043,color:#fff
    style LN fill:#FF7043,color:#fff
    style PF fill:#66BB6A,color:#fff
    style POSE fill:#AB47BC,color:#fff

The sigma_* parameters control how hard each observation snaps the filter. A lower sigma means “I trust this observation more than my own estimate.” Physical wall contact (sigma_xy_cm=0.3–0.75) is more reliable than manual start placement (sigma_xy_cm=1.0–3.0).

start_pose — Anchoring to a Known Competition Start

Most competition robots start in a fixed, known position on the table. The start_pose field in robot.yml tells the localization system where the robot begins, so the filter starts anchored rather than at the origin:

# config/robot.yml
physical:
  table_map: config/2026-game-table.ftmap
  start_pose:
    x_cm: 156.31
    y_cm: 75.78
    theta_deg: 0.31

Without start_pose, localization begins at (0, 0, 0). If your robot starts at a known position, always set start_pose — it prevents an initial jump in the localization trace and makes the first resync_at_start_pose() call more effective.

In mission code, resync_at_start_pose() with explicit expected coordinates anchors the particle cloud to the configured start location right after wait_for_light():

seq([
    wait_for_light(),
    mark_heading_reference(),
    resync_at_start_pose(
        expected_x_cm=156.31,
        expected_y_cm=75.78,
        expected_theta_deg=0.31,
        sigma_xy_cm=1.5,       # allow ±1.5 cm start placement uncertainty
        sigma_theta_deg=3.0,
    ),
    # rest of mission
])

How It Works

graph LR
    ODO["Odometry
(IOdometry)"] -->|"per-tick delta"| LOC["Localization
(particle filter)"] OBS["Resync observation
(wall, line, start pose)"] -->|"sigma-weighted snap"| LOC LOC --> POSE["robot.localization.get_pose()"] style ODO fill:#42A5F5,color:#fff style OBS fill:#FF7043,color:#fff style LOC fill:#66BB6A,color:#fff style POSE fill:#AB47BC,color:#fff

The particle filter runs in a background thread at 100 Hz. On each tick it:

  1. Reads the per-tick odometry delta and propagates the particle cloud forward (predict step)
  2. When a resync observation arrives, weights and resamples the cloud (update step)
  3. Outputs the weighted-mean pose as the world estimate

The world pose from robot.localization.get_pose() is what smooth-path segments use for heading hold and what resync steps report as “current position”. It is more stable than raw odometry because it incorporates absolute observations at known landmarks.

Auto-Wiring

robot.localization is automatically constructed on first access. You do not instantiate it directly. The auto-wire logic wraps robot.odometry with default LocalizationConfig settings and the optional robot.table_map (for surface-measurement likelihoods):

# This just works — no setup needed
pose = robot.localization.get_pose()

The instance is cached in robot._localization after the first access. If auto-wiring fails (missing extension, broken odometry), LocalizationNotWiredError is raised with the root cause chained via __cause__.

Tuning with LocalizationConfig

The default LocalizationConfig is adequate for most robots without further tuning. You can customize it by constructing Localization manually and assigning it to robot._localization in your robot’s __init__:

from raccoon import Localization, LocalizationConfig

class MyRobot(MissionProtocol):
    def __init__(self):
        super().__init__()
        cfg = LocalizationConfig(
            particle_count=256,                   # more particles = more accurate, more CPU
            process_translation_noise_m=0.003,    # per-tick base translation noise
        )
        self._localization = Localization(self.odometry, cfg, table_map=self.table_map)

LocalizationConfig Parameters

All parameters are optional; the defaults are shown below.

ParameterDefaultUnitDescription
tick_period_ms10msBackground tick period. 10 ms = 100 Hz. Lower = more responsive, higher CPU use.
particle_count128countNumber of particles in the filter. More particles improve accuracy at the cost of CPU time. 128 is the practical sweet spot for Wombat; 256 is safe for short missions.
process_translation_noise_m0.002m/tickBase (unconditional) translation noise injected per tick. Prevents the filter from becoming overconfident during stationary periods.
process_translation_noise_per_m0.02m/mTranslation noise that scales with how far the robot moved in that tick. Models wheel slip proportional to distance.
process_heading_noise_rad0.01rad/tickBase heading noise per tick.
process_heading_noise_per_rad0.05rad/radHeading noise that scales with how much the robot rotated in that tick.
observation_injection_ratio0.35fractionFraction of particles that are re-initialised from the observation distribution on each update step (merges new information with existing estimate).
resample_effective_sample_ratio0.5fractionResampling is triggered when the effective sample size falls below this ratio times particle_count. Lower values = less frequent resampling.

The most commonly adjusted parameters are particle_count (for accuracy) and process_translation_noise_per_m (for how much the filter trusts odometry vs. observations).

Resync Steps

Resync steps inject absolute pose observations into the localization filter at known field positions. Each observation is a soft snap — it updates the particle cloud with a Gaussian likelihood centred on the expected pose. The sigma_xy_cm and sigma_theta_deg parameters control how tightly the filter is snapped to the expected values. Smaller sigma = harder snap.

The step is a no-op if localization is not wired (robot.localization raises LocalizationNotWiredError) — it logs a warning and continues.

resync_at_start_pose()

Inject the robot’s known start pose directly, without any additional motion. Use this at the very beginning of a run to anchor the particle cloud to the known starting position.

from raccoon.step.motion import resync_at_start_pose

# Anchor to the origin (robot started at x=0, y=0, theta=0)
resync_at_start_pose(
    expected_x_cm=0.0,
    expected_y_cm=0.0,
    expected_theta_deg=0.0,
)

# Use current odometric position (no override) — just tighten the filter
resync_at_start_pose()

# Snap only X and Y, leave heading free
resync_at_start_pose(
    expected_x_cm=0.0,
    expected_y_cm=0.0,
    snap_axes=(True, True, False),
)

Parameters:

ParameterTypeDefaultDescription
expected_x_cmfloat | NoneNoneExpected X position in cm. None = use current odometric X.
expected_y_cmfloat | NoneNoneExpected Y position in cm. None = use current odometric Y.
expected_theta_degfloat | NoneNoneExpected heading in degrees. None = use current odometric heading.
snap_axes(bool, bool, bool)(True, True, True)Which axes (x, y, theta) to include in the observation.
sigma_xy_cmfloat1.0Position uncertainty standard deviation in cm. Smaller = harder snap.
sigma_theta_degfloat5.0Heading uncertainty standard deviation in degrees.

Typical usage:

seq([
    wait_for_light(),
    resync_at_start_pose(
        expected_x_cm=0.0,
        expected_y_cm=0.0,
        expected_theta_deg=0.0,
        sigma_xy_cm=1.0,       # 1 cm uncertainty in position
        sigma_theta_deg=3.0,   # 3° uncertainty in heading
    ),
    # ... rest of mission
])

find_line_resync()

Drive until an IR sensor detects a line, then inject an observation at that known line position. This is the most useful resync step for Botball — game table lines are precisely positioned and you can rely on them as absolute landmarks.

from raccoon.step.motion import find_line_resync
from raccoon.map import SensorOffset

# Drive until the right sensor hits the known scoring line at Y=50 cm
find_line_resync(
    sensor=Defs.front.right,
    sensor_position=SensorOffset(forward_cm=5.0, strafe_cm=-8.0),  # sensor position on robot
    expected_y_cm=50.0,     # we know this line is at Y=50 cm
    snap_axes=(False, True, False),  # snap only Y (line is horizontal)
    sigma_xy_cm=0.75,       # 0.75 cm uncertainty along the line normal
    threshold=0.7,          # sensor detection threshold
    forward_speed_mps=0.15, # slow approach for accurate detection
    timeout_s=3.0,          # give up after 3 seconds
)

Parameters:

ParameterTypeDefaultDescription
sensorIRSensorrequiredThe IR sensor to detect the line
sensor_positionSensorOffsetrequiredPosition of the sensor on the robot body (forward_cm, strafe_cm from robot centre)
expected_x_cmfloat | NoneNoneExpected X when the line is detected. None = use current position.
expected_y_cmfloat | NoneNoneExpected Y when the line is detected. None = use current position.
expected_theta_degfloat | NoneNoneExpected heading. None = use current.
snap_axes(bool, bool, bool)(False, True, False)Which axes to snap. Default: Y only (horizontal line).
sigma_xy_cmfloat1.0Position uncertainty in cm.
sigma_theta_degfloat5.0Heading uncertainty in degrees.
line_sigma_cmfloat0.75Uncertainty assigned to the line surface measurement specifically.
thresholdfloat0.7probabilityOfBlack() threshold for triggering detection.
forward_speed_mpsfloat0.15Forward drive speed in m/s while searching.
strafe_speed_mpsfloat0.0Lateral speed component (for mecanum diagonal approaches).
timeout_sfloat3.0Max time in seconds to drive before giving up.

Choosing snap_axes: A vertical line (robot drives left/right across it) is best snapped on X only: (True, False, False). A horizontal line (robot drives forward across it) is best snapped on Y only: (False, True, False). Snap heading only if you are confident the detection gives you a reliable heading reference.

Example: resync on a horizontal scoring zone boundary

from raccoon.map import SensorOffset

seq([
    # Drive north until the front sensor crosses the scoring zone line
    find_line_resync(
        sensor=Defs.front.right,
        sensor_position=SensorOffset(forward_cm=8.0, strafe_cm=-6.0),
        expected_y_cm=90.0,           # scoring zone boundary at Y=90 cm
        snap_axes=(False, True, False),
        sigma_xy_cm=0.75,
        forward_speed_mps=0.15,
    ),
    # Continue mission — localization is now anchored at Y=90 cm
    drive_forward(10),
])

align_to_wall_resync()

Run a wall-alignment step (drives into a wall until stall), then inject a wall-based observation. This combines wall_align_*() with a pose snap in one step.

from raccoon.step.motion import align_to_wall_resync
from raccoon.step.motion.wall_align import WallDirection

# Drive backward into the back wall and snap X position
align_to_wall_resync(
    direction=WallDirection.BACKWARD,
    expected_x_cm=0.0,           # back wall is at X=0
    snap_axes=(True, False, False),
    sigma_xy_cm=0.5,             # wall is very precise: 0.5 cm
    sigma_theta_deg=3.0,
    speed=0.5,
    accel_threshold=0.5,
    settle_duration=0.2,
    max_duration=5.0,
)

The direction parameter controls which wall-align step runs internally:

directionEquivalent stepTypical use
WallDirection.FORWARDwall_align_forward()Align against front wall
WallDirection.BACKWARDwall_align_backward()Align against back wall
WallDirection.STRAFE_LEFTwall_align_strafe_left()Align against left wall
WallDirection.STRAFE_RIGHTwall_align_strafe_right()Align against right wall

Parameters:

ParameterTypeDefaultDescription
directionWallDirectionWallDirection.FORWARDWhich direction to drive into the wall
expected_x_cmfloat | NoneNoneExpected X when aligned.
expected_y_cmfloat | NoneNoneExpected Y when aligned.
expected_theta_degfloat | NoneNoneExpected heading.
snap_axes(bool, bool, bool)(True, True, True)Which axes to snap.
sigma_xy_cmfloat1.0Position uncertainty in cm.
sigma_theta_degfloat5.0Heading uncertainty in degrees.
wall_sigma_cmfloat0.75Uncertainty for the wall surface measurement.
sensor_positionSensorOffset | NoneNonePosition of the bumper/wall sensor on the robot (for likelihood computation).
speedfloat1.0Wall-align drive speed.
accel_thresholdfloat0.5Deceleration threshold to detect the wall impact.
settle_durationfloat0.2Seconds to wait after impact for the robot to settle.
max_durationfloat5.0Maximum seconds to drive before giving up.
grace_periodfloat0.3Grace period in seconds before stall detection activates.

Example: setup sequence with wall-back and start-pose resync

seq([
    wait_for_light(),

    # Anchor heading at light-on time
    mark_heading_reference(),

    # Align against back wall and snap position
    align_to_wall_resync(
        direction=WallDirection.BACKWARD,
        expected_x_cm=0.0,
        expected_y_cm=0.0,
        expected_theta_deg=0.0,
        snap_axes=(True, True, True),
        sigma_xy_cm=0.5,
        sigma_theta_deg=2.0,
    ),

    # Begin mission
    drive_forward(50),
])

Sigma Values — Practical Guide

The sigma_* parameters control the strength of each observation. A lower sigma = a harder snap = the filter trusts this observation more than its own internal estimate.

LandmarkTypical sigma_xy_cmNotes
Wall (physical contact)0.3–0.75Very reliable: physical contact is repeatable
IR line detection0.5–1.0Depends on line edge quality and sensor position accuracy
Start pose (manual placement)1.0–3.0Higher if start placement is imprecise
Run-to-run start variation3.0–5.0Large uncertainty: don’t rely on this alone

Start with the defaults (sigma_xy_cm=1.0, sigma_theta_deg=5.0) and tighten them as you gain confidence in the landmark repeatability.

Debugging Localization

Enable JSONL recording by setting environment variables before running:

LIBSTP_RECORD_LOCALIZATION=1 \
LIBSTP_RECORDING_PATH=/tmp/loc_run.jsonl \
raccoon run my_mission.py

The recording captures per-tick particle clouds, observations, and world-pose estimates for post-run analysis.