Fast Robots

ECE 5160 Spring 2026

← Back to Labs

Lab 11: Localization on the Real Robot

Objective

The goal of this lab was to run the Bayes filter on the actual robot and compare localization accuracy against simulation. Because the robot's motion is too noisy to get anything useful out of a prediction step, only the update step is used: the robot spins 360° in place, takes a ToF reading every 20°, and those 18 measurements are fed into the filter starting from a uniform prior. This was tested at four marked positions in the lab space.

Simulation

First I ran lab11_sim.ipynb to confirm the Bayes filter implementation was working before touching the real robot. The belief tracked the ground truth closely throughout the trajectory, which matched what was seen in Lab 10.

Simulation result: odom (red), ground truth (green), belief (blue)
Figure 1: Simulation result. Red = odometry, green = ground truth, blue = belief.

The simulation confirmed that the provided localization code is functional, so any issues on the real robot would come down to sensor data quality rather than the filter itself.

Real Robot Implementation

To get sensor data from the real robot I implemented perform_observation_loop() in the RealRobot class. This reuses the step-based yaw PID approach from Lab 9: a YAW_PID_START command is sent for each 20° increment, which tells the Artemis to rotate to that heading, hold it with PID, and log a ToF reading. After all 18 steps complete, LAB9_SESSION_GET_DATA flushes all rows back over BLE as L9ROW:<yaw>,<tof_mm> notifications. The code also pulls from the provided demo notebook and localization extras, which I modified to work with my existing BLE command structure.

Python
def perform_observation_loop(self, rot_vel=120):
    obs_count = int(self.config_params.get("observation_count", 18))
    step_deg  = int(round(360.0 / obs_count))   # 20 degrees

    kp, ki, kd      = 0.1, 0.2, 0.01
    duration_ms      = 1500
    min_pwm, max_pwm = 120, 255
    loop_period_ms   = 10

    rows = []

    def _notif_handler(_uuid, byte_array):
        raw = self.ble.bytearray_to_string(byte_array).strip()
        if not raw.startswith("L9ROW:"):
            return
        parts = raw[6:].split(",")
        if len(parts) == 2:
            try:
                rows.append((float(parts[0]), int(parts[1])))
            except ValueError:
                pass

    self.ble.start_notify(self.ble.uuid["RX_STRING"], _notif_handler)
    try:
        self.ble.send_command(CMD.LAB9_TOF_CLEAR, "")
        time.sleep(0.05)

        for _ in range(obs_count):
            payload = (f"{step_deg}|{kp}|{ki}|{kd}|"
                       f"{duration_ms}|{min_pwm}|{max_pwm}|{loop_period_ms}")
            self.ble.send_command(CMD.YAW_PID_START, payload)

        rows.clear()
        self.ble.send_command(CMD.LAB9_SESSION_GET_DATA, "")
        time.sleep(7.0)
    finally:
        self.ble.stop_notify(self.ble.uuid["RX_STRING"])

    ranges_m, bearings_deg = [], []
    for i, (_yaw, tof_mm) in enumerate(rows[:obs_count]):
        ranges_m.append(tof_mm / 1000.0 if tof_mm >= 0 else np.nan)
        bearings_deg.append(float(i * step_deg))

    while len(ranges_m) < obs_count:
        ranges_m.append(np.nan)
        bearings_deg.append(float(len(bearings_deg) * step_deg))

    sensor_ranges   = np.array(ranges_m,   dtype=float)[np.newaxis].T
    sensor_bearings = np.array(bearings_deg, dtype=float)[np.newaxis].T
    return sensor_ranges, sensor_bearings

The 18 YAW_PID_START commands are sent back-to-back and the Artemis queues them, running each step after the previous one finishes. Because each command carries the same PID gains, clamp limits, and hold duration, every angular sample is taken under consistent controller settings, which keeps the scan more repeatable from run to run.

After the command queue drains, the host requests all logged rows in one transfer and keeps notifications open long enough for the full packet stream to arrive. The parser only accepts L9ROW: frames and ignores anything malformed, so bad BLE text does not poison the observation array. Finally, ToF values are converted from mm to meters, missing entries are padded with NaN, and both ranges and bearings are returned as column vectors matching the localization module interface.

Localization Results

The robot was placed at each of the four marked poses and the update step was run from a uniform prior. Green marker = ground truth, blue marker = filter belief.

Belief plot for pose (-3,-2)
Plotter (−3,−2)
Belief plot for pose (0,3)
Plotter (0,3)
Belief plot for pose (5,-3)
Plotter (5,−3)
Belief plot for pose (5,3)
Plotter (5,3)

Discussion

All four poses localized reasonably well. The step-based rotation was a big part of why: stopping at each 20° increment with the yaw PID means the ToF reading is taken while the robot is stationary, so each sample is cleanly tied to its nominal angle. Continuous spinning would introduce a timing mismatch that corrupts the angular assignment.

Relative to ground truth, each estimate landed in the correct region with a modest but noticeable margin of error, which is expected from ToF noise, heading quantization at 20° steps, and small repeatability limits in real robot motion.

The 18-angle sweep gives the filter enough angular coverage that even poses without a wall directly nearby still produce a distinctive enough signature to constrain the belief. Starting from a uniform prior and skipping the prediction step entirely also helped. On a real robot, wheel slip and motor imbalance would just smear the belief if a motion model were involved, so letting the sensor data do all the work each spin was the right call.