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
| Movement | Left target | Right target | Interpretation |
|---|---|---|---|
| Straight | 240 mm | 240 mm | Equal side travel |
| Right curve | 330 mm | 150 mm | Left is outside |
| Left curve | 150 mm | 330 mm | Right is outside |
| Right pivot | 180 mm | 0 mm | Right 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:
| Quantity | Calculation | Result |
|---|---|---|
| Centre path | (330 + 150) / 2 | 240 mm |
| Right-positive angle | (330 − 150) / 300 | 0.6 rad ≈ 34.38° right |
| Centre radius | 240 / 0.6 | 400 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
| Measured left | Measured right | Left progress | Right progress |
|---|---|---|---|
| 165 mm | 75 mm | 0.50 | 0.50 |
| 198 mm | 75 mm | 0.60 | 0.50 |
| 330 mm | 150 mm | 1.00 | 1.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
- Verify direction and encoder signs with supported wheels.
- Run equal short distances on a clear level surface and check straight-line behaviour.
- Run the supplied right curve, measuring the final centre position and heading.
- Swap distances for a left curve and compare three trials of each.
- Inspect progress reporting and identify systematic side mismatch.
- 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
| Trial | Left target / measured | Right target / measured | Progress mismatch | Physical result |
|---|---|---|---|---|
| 1 | Record | Record | Record | Position and heading |
| 2 | Record | Record | Record | Position and heading |
| 3 | Record | Record | Record | Position 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
- Identify: label inside and outside sides for left and right forward curves.
- Calculate: use left 450 mm, right 250 mm and track 300 mm to find centre path, heading change and radius.
- Set speeds: calculate both commands with a 24% maximum.
- Compare progress: calculate fractions when measured travel is left 225 mm and right 100 mm.
- Test: swap the supplied distances to produce a left curve.
- Explain: distinguish equal fractional progress from equal raw distance.
- Evaluate: explain why matching endpoints does not prove a constant-radius path.
- 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.