VEX V5 PYTHON | 03.4 INSIDE AND OUTSIDE WHEEL DISTANCES

Guide objective

Set separate side distances, map inside and outside wheels correctly and compare encoder progress as both sides move towards their own targets.

01 | ONE MOVEMENT CAN HAVE TWO DISTANCE TARGETS

In 03.3, radius and centre-path length produced an inside distance and an outside distance. Now work directly with those separate side targets.

For a forward right curve, the left side is outside and travels farther. For a forward left curve, the right side is outside. Inside and outside describe the turn, not fixed sides of the chassis.

Separate targets also describe straight travel and a pivot. The helper here accepts non-negative forward distances; reverse and opposite-direction point turns need a signed-distance extension.

02 | LABEL THE PATHS BEFORE CODING

Forward-distance conventions used on this page
MovementLeft targetRight targetInterpretation
Straight240 mm240 mmEqual side travel
Right curve330 mm150 mmLeft is outside
Left curve150 mm330 mmRight is outside
Right pivot180 mm0 mmRight side held stationary in the ideal model

Draw an arrow showing the starting forward direction, then label LEFT and RIGHT from the robot’s viewpoint. Looking at a robot from the front reverses your visual perspective.

Always pass distances to the correct side names. Changing motor reversal flags to fix swapped inside/outside targets hides the actual problem.

03 | RECOVER THE CURVE FROM TWO DISTANCES

With ideal rolling and track width T, the centre travels the average of the two side distances. The right-positive heading change depends on their difference.

centre_path = (left_distance + right_distance) / 2
right_positive_angle_radians = (left_distance − right_distance) / T
centre_radius = centre_path / abs(angle_radians)

For left = 330 mm, right = 150 mm and T = 300 mm:

Different distances describe a predictable ideal curve
QuantityCalculationResult
Centre path(330 + 150) / 2240 mm
Right-positive angle(330 − 150) / 3000.6 rad ≈ 34.38° right
Centre radius240 / 0.6400 mm

Equal distances give zero angle and a straight line. Do not divide by zero to calculate a radius; describe it as straight travel instead.

These endpoint totals imply a constant-radius arc only when side progress maintains the intended ratio. Different movement sequences can produce the same totals but a different path.

04 | SET SPEEDS TO MATCH THE DISTANCES

For both sides to finish together under the ideal constant-speed model, speed ratio must match distance ratio. Give the longer path the chosen maximum command, then scale the shorter path.

longest = max(left_mm, right_mm)
left_speed = max_speed × left_mm / longest
right_speed = max_speed × right_mm / longest

For 330 mm left, 150 mm right and a 20% maximum command: left = 20%, right ≈ 9.09%.

Giving both sides 20% would make the short side finish first, altering the curve near the end. The distance targets alone do not establish the path through time.

Percentage scaling assumes equal wheel sizes, gearing and cartridges. Confirm measured response as well: a low inside command may struggle under load.

05 | CONVERT EACH SIDE INDEPENDENTLY

left_degrees = left_mm / wheel_travel_mm × 360
right_degrees = right_mm / wheel_travel_mm × 360

With a 101.6 mm wheel and direct drive, nominal wheel travel is approximately 319.19 mm. The 330 mm side target is about 372.20°; the 150 mm side target is about 169.18°.

These are motor output-shaft rotation targets. The robot’s heading change is about 34.38°, a different quantity.

The sample assumes equal, direct-drive wheels. If wheel size or external gearing differs between sides, use separate calibrated conversion factors and comparable wheel-speed units.

06 | BUILD A SMALL SIDE-PLANNING FUNCTION

def side_plan(left_mm, right_mm, max_speed):
    for value in (left_mm, right_mm):
        if (not isinstance(value, (int, float))
                or not 0 <= value < float("inf")):
            raise ValueError("This starter accepts finite non-negative distances")
    longest = max(left_mm, right_mm)
    if longest == 0:
        raise ValueError("At least one side must move")
    if not isinstance(max_speed, (int, float)) or not 0 < max_speed <= 100:
        raise ValueError("Max speed must be above 0 and at most 100")
    left_degrees = left_mm / WHEEL_TRAVEL_MM * 360
    right_degrees = right_mm / WHEEL_TRAVEL_MM * 360
    left_speed = max_speed * left_mm / longest
    right_speed = max_speed * right_mm / longest
    return left_degrees, right_degrees, left_speed, right_speed

The function returns two rotation targets and two speed commands. It rejects negative distances and a request where neither side moves.

Zero on one side is supported as a pivot. The code stops that group explicitly and does not issue a zero-distance spin_for at zero speed.

A very small positive target can produce a very low speed. Check whether that command is practical before testing. Choose a wider curve or a suitable controller rather than assuming every numerical plan is achievable.

07 | START TOGETHER, MONITOR TOGETHER

Start both non-zero target commands with wait=False. A blocking first command would produce one side movement followed by the other.

Then monitor both groups in one loop. The movement ends only when both report completion, or a cancellation, mode change or deadline takes priority.

Read each motor’s initial encoder position and compare the later change with its target. There is no need to reset positions, which could disrupt another part of the program.

Check both motors on each side at the end rather than relying on a side average that could hide one incorrect reading.

08 | COMPARE FRACTIONAL PROGRESS

Raw millimetres are misleading when the targets differ. Compare the fraction of each target completed.

left_progress = left_travel / left_target
right_progress = right_travel / right_target
Use left target 330 mm and right target 150 mm
Measured leftMeasured rightLeft progressRight progress
165 mm75 mm0.500.50
198 mm75 mm0.600.50
330 mm150 mm1.001.00

In the second row, the left side is further through its planned path. That is a progress mismatch even though it is supposed to travel more millimetres.

The sample prints side-average encoder progress every 200 ms. A stationary side reports None because dividing by its zero target would be invalid. Values are not clamped to 1, so overshoot remains visible.

This is diagnostic reporting, not an active synchronisation controller. The code does not correct the mismatch during motion.

09 | COMPLETE SEPARATE-DISTANCE PROJECT

Keep generated imports and Brain setup above this block, including import math. Paste below # Begin project code, outside the generated region, replacing the previous starter.

Match the explicitly declared ports, cartridges and reversal flags to your robot. Do not duplicate these devices in the Devices panel. Confirm that FORWARD means robot-forward on both sides.

The example uses direct drive and equal wheels. Autonomous attempts one short forward right curve: left 330 mm, right 150 mm. Driver control retains the established split-stick scheme.

# =============================================================================
# TEAM [MY TEAM NAME / NUMBER]
# =============================================================================
# ROBOT CONFIGURATION
# - FOUR-motor drivetrain:
#       Left  = Ports 3 and 4
#       Right = Ports 1 and 2
# - Example: blue cartridges, left normal, right reversed.
#   VERIFY your actual ports, cartridges and direction settings.
# DRIVER CONTROLS
# - Left joystick vertical (axis3): forward/back
# - Right joystick horizontal (axis1): turn
# - Autonomous: left 330 mm, right 150 mm (forward right curve).
# - Hold B to cancel the autonomous arc.
# - Driver control retains left axis3 / right axis1.
# - Add BELOW # Begin project code, outside generated configuration.
# - Do not also define these drive motors in the Devices panel.
# - Keep generated from vex import * and brain = Brain() above.

# =============================================================================
# FOUR-MOTOR DRIVETRAIN
# =============================================================================
left_motor_1 = Motor(Ports.PORT3, GearSetting.RATIO_6_1, False)
left_motor_2 = Motor(Ports.PORT4, GearSetting.RATIO_6_1, False)
right_motor_1 = Motor(Ports.PORT1, GearSetting.RATIO_6_1, True)
right_motor_2 = Motor(Ports.PORT2, GearSetting.RATIO_6_1, True)

left_drive = MotorGroup(left_motor_1, left_motor_2)
right_drive = MotorGroup(right_motor_1, right_motor_2)

# =============================================================================
# CONTROLLER, SETTINGS AND HELPERS
# =============================================================================
controller = Controller(PRIMARY)  # Do not duplicate a generated Controller
DEADBAND = 5
DRIVE_LIMIT_PERCENT = 30          # Start low; increase only after testing
TURN_GAIN = 0.7
LOOP_DELAY_MS = 20
TRACK_MM = 300                # Left/right wheel contact centre spacing
WHEEL_DIAMETER_MM = 101.6
WHEEL_TRAVEL_MM = math.pi * WHEEL_DIAMETER_MM
SIDE_MAX_PERCENT = 20
ARC_LIMIT_MS = 5000
ENCODER_TOLERANCE_DEGREES = 10
# Direct drive, equal wheels and matching cartridges on both sides assumed.

def apply_deadband(value):
    return 0 if abs(value) <= DEADBAND else value

def drive_mix(forward, turn):
    left = forward + turn
    right = forward - turn
    largest = max(100, abs(left), abs(right))
    left = left * 100 / largest
    right = right * 100 / largest
    return (left * DRIVE_LIMIT_PERCENT / 100,
            right * DRIVE_LIMIT_PERCENT / 100)

def set_side_output(side, output):
    if abs(output) < 0.01:
        side.stop(BRAKE)
    elif output > 0:
        side.spin(FORWARD, output, PERCENT)
    else:
        side.spin(REVERSE, -output, PERCENT)

def side_plan(left_mm, right_mm, max_speed):
    for value in (left_mm, right_mm):
        if (not isinstance(value, (int, float))
                or not 0 <= value < float("inf")):
            raise ValueError("This starter accepts finite non-negative distances")
    longest = max(left_mm, right_mm)
    if longest == 0:
        raise ValueError("At least one side must move")
    if not isinstance(max_speed, (int, float)) or not 0 < max_speed <= 100:
        raise ValueError("Max speed must be above 0 and at most 100")
    left_degrees = left_mm / WHEEL_TRAVEL_MM * 360
    right_degrees = right_mm / WHEEL_TRAVEL_MM * 360
    left_speed = max_speed * left_mm / longest
    right_speed = max_speed * right_mm / longest
    return left_degrees, right_degrees, left_speed, right_speed

def arc_stop_reason():
    if controller.buttonB.pressing():
        return "CANCELLED"
    if not (competition.is_enabled() and competition.is_autonomous()):
        return "MODE_CHANGED"
    return None

def drive_side_distances(left_mm, right_mm, max_speed=None):
    speed = SIDE_MAX_PERCENT if max_speed is None else max_speed
    left_degrees, right_degrees, left_speed, right_speed = side_plan(
        left_mm, right_mm, speed)
    reason = arc_stop_reason()
    if reason is not None:
        stop_drive()
        return reason

    motors = (left_motor_1, left_motor_2, right_motor_1, right_motor_2)
    starts = [motor.position(DEGREES) for motor in motors]
    targets = (left_degrees, left_degrees, right_degrees, right_degrees)
    start_ms = brain.timer.time(MSEC)
    next_report_ms = start_ms
    try:
        left_drive.set_stopping(BRAKE)
        right_drive.set_stopping(BRAKE)
        left_drive.set_timeout(ARC_LIMIT_MS + 500, MSEC)
        right_drive.set_timeout(ARC_LIMIT_MS + 500, MSEC)
        # Both sides start without waiting for the first to finish.
        if left_degrees > 0:
            left_drive.spin_for(FORWARD, left_degrees, DEGREES,
                                left_speed, PERCENT, wait=False)
        else:
            left_drive.stop(BRAKE)
        if right_degrees > 0:
            right_drive.spin_for(FORWARD, right_degrees, DEGREES,
                                 right_speed, PERCENT, wait=False)
        else:
            right_drive.stop(BRAKE)
        wait(LOOP_DELAY_MS, MSEC)
        while True:
            reason = arc_stop_reason()
            if reason is not None:
                return reason
            if brain.timer.time(MSEC) - start_ms >= ARC_LIMIT_MS:
                return "TIMEOUT"
            now_ms = brain.timer.time(MSEC)
            if now_ms >= next_report_ms:
                changes = [motor.position(DEGREES) - start
                           for motor, start in zip(motors, starts)]
                left_progress = ((changes[0] + changes[1]) / 2 / left_degrees
                                 if left_degrees > 0 else None)
                right_progress = ((changes[2] + changes[3]) / 2 / right_degrees
                                  if right_degrees > 0 else None)
                print("PROGRESS", left_progress, right_progress)
                next_report_ms = now_ms + 200
            left_done = left_degrees == 0 or left_drive.is_done()
            right_done = right_degrees == 0 or right_drive.is_done()
            if left_done and right_done:
                stop_drive()
                wait(100, MSEC)
                reason = arc_stop_reason()
                if reason is not None:
                    return reason
                if brain.timer.time(MSEC) - start_ms >= ARC_LIMIT_MS:
                    return "TIMEOUT"
                errors = [abs((motor.position(DEGREES) - start) - target)
                          for motor, start, target in zip(motors, starts, targets)]
                if max(errors) <= ENCODER_TOLERANCE_DEGREES:
                    return "ENCODER_TARGETS_CONFIRMED"
                return "ENDED_OFF_TARGET"
            wait(LOOP_DELAY_MS, MSEC)
    finally:
        stop_drive()

def show_status(message):
    brain.screen.clear_screen()
    brain.screen.set_cursor(1, 1)
    brain.screen.print(message)

def stop_drive():
    left_drive.stop(BRAKE)
    right_drive.stop(BRAKE)

# =============================================================================
# PRE-AUTON: CALLED ONCE AT PROJECT STARTUP
# =============================================================================
def pre_autonomous():
    stop_drive()
    show_status("Setup ready")
    # Later: initialise sensors and starting values here.
    # Finish setup before the robot is enabled for a match.

# =============================================================================
# AUTONOMOUS: A FINITE ROUTINE
# =============================================================================
def autonomous():
    show_status("Separate side targets")
    result = drive_side_distances(left_mm=330, right_mm=150)
    show_status(result)
    # No next move: inspect physical path and final heading first.

# =============================================================================
# DRIVER CONTROL: REPEATING UPDATES WHILE ENABLED
# =============================================================================
def driver_control():
    show_status("Driver control")
    while competition.is_enabled() and competition.is_driver_control():
        forward = apply_deadband(controller.axis3.position())
        turn = apply_deadband(controller.axis1.position()) * TURN_GAIN
        left_output, right_output = drive_mix(forward, turn)
        set_side_output(left_drive, left_output)
        set_side_output(right_drive, right_output)
        wait(LOOP_DELAY_MS, MSEC)
    stop_drive()

# =============================================================================
# PROGRAM START: SETUP, THEN CALLBACK REGISTRATION
# =============================================================================
pre_autonomous()
competition = Competition(driver_control, autonomous)

# Keep the main task alive without running a driving loop here.
while True:
    wait(100, MSEC)

ENCODER_TARGETS_CONFIRMED means all four motor changes are within the chosen tolerance after both groups finish. It does not confirm physical ground distance, heading or obstacle clearance.

Holding B cancels autonomous. A timeout, cancellation or mode change stops both groups through cleanup. No further autonomous movement follows this test.

For a pivot test, the nominally stationary side is checked for encoder displacement too. Braking does not guarantee that its wheels or chassis remain fixed on the floor.

10 | SYNCHRONISATION IS MORE THAN FINAL ACCURACY

Both sides can reach their final targets while following an uneven curve. If one accelerates sooner or slows earlier, the turning radius changes during the movement.

Plot or compare fractional progress at several points. A future controller could use the progress difference to adjust side speeds, but it must preserve the planned direction, handle short targets and stop conditions, and avoid fighting the motor target controllers.

Do not add a separate task that continuously changes outputs while these spin_for commands also own the motors. A coordinated motion profile should have one clear control owner.

The next guide introduces planned acceleration, cruising and deceleration with trapezoidal profiles and feedforward.

11 | TEST STRAIGHT, CURVE AND PIVOT SEPARATELY

  1. Verify direction and encoder signs with supported wheels.
  2. Run equal short distances on a clear level surface and check straight-line behaviour.
  3. Run the supplied right curve, measuring the final centre position and heading.
  4. Swap distances for a left curve and compare three trials of each.
  5. Inspect progress reporting and identify systematic side mismatch.
  6. Only then test a short one-side-zero pivot with clearance for the full robot footprint.

Keep speed, battery state, payload and surface comparable. A full four-wheel chassis scrubs during turning, so ideal rolling geometry is a prediction to verify rather than a guaranteed radius.

12 | RECORD WHAT EACH SIDE DID

Keep encoder outcomes separate from observed ground movement
TrialLeft target / measuredRight target / measuredProgress mismatchPhysical result
1RecordRecordRecordPosition and heading
2RecordRecordRecordPosition and heading
3RecordRecordRecordPosition and heading

If encoder targets are accurate but the curve is wrong, investigate slip, tyre scrub, effective track and temporal synchronisation. If encoder targets are wrong, inspect timeout, motor configuration and mechanical load first.

Compare left and right motor readings within a group. A loose coupling or wheel problem may not be visible from a final group-average value.

CHALLENGES | PLAN BOTH SIDES

  1. Identify: label inside and outside sides for left and right forward curves.
  2. Calculate: use left 450 mm, right 250 mm and track 300 mm to find centre path, heading change and radius.
  3. Set speeds: calculate both commands with a 24% maximum.
  4. Compare progress: calculate fractions when measured travel is left 225 mm and right 100 mm.
  5. Test: swap the supplied distances to produce a left curve.
  6. Explain: distinguish equal fractional progress from equal raw distance.
  7. Evaluate: explain why matching endpoints does not prove a constant-radius path.
  8. Document: record three trials with encoder and physical outcomes.
Check the calculations

Centre path = 350 mm. Right-positive angle = 200 / 300 ≈ 0.6667 rad ≈ 38.20°. Radius = 350 / 0.6667 ≈ 525 mm. Left command = 24%, right ≈ 13.33%. Progress fractions are 0.50 left and 0.40 right, so left is ahead by 0.10 of its target.

OFFICIAL REFERENCES

The geometry and monitoring helper are team-written logic. This is an independent learning resource; VEX and VEXcode are trademarks of their respective owners.