← All modules

5.4 Control Algorithms

Draft — not verified

Theoretical Background: Control Algorithms and Obstacle Avoidance

Module 5 Theory: Control Algorithms and Obstacle Avoidance

Learning Objectives

  • Understand the four control algorithms implemented in useControlAlgorithms.ts
  • Analyse the PID anti-windup mechanism and its necessity in mobile robot control
  • Understand the State Machine controller’s explicit alignment phase
  • Study the four obstacle avoidance modes in useObstacleAvoidance.ts
  • Apply the useAlgorithmComparison composable to benchmark controller performance

5.4.1 Control Algorithm Framework

All four algorithms share the same input vector and output structure, enabling runtime switching without restarting the simulation:

// useControlAlgorithms.ts
export type ControlAlgorithm =
  'proportional' | 'pid' | 'pure_pursuit' | 'state_machine'

interface ControlOutput {
  linear:    number            // m/s
  angular:   number            // rad/s
  algorithm: ControlAlgorithm
  timestamp: number            // Date.now()
}

// Tuned defaults for the 12-obstacle simulation field
const params = reactive<AlgorithmParams>({
  proportional: { kp_linear: 0.6,  kp_angular: 2.0 },
  pid: {
    kp_linear: 0.8,  ki_linear: 0.05,  kd_linear: 0.15,
    kp_angular: 2.5, ki_angular: 0.03, kd_angular: 0.2,
    integral_limit: 0.5,
  },
  pure_pursuit: { lookahead_distance: 0.5, kp_linear: 0.6 },
  state_machine: { arrival_tolerance: 0.15, heading_tolerance: 0.12 },
})

const maxLinearVel  = ref(0.7)   // m/s
const maxAngularVel = ref(1.5)   // rad/s

Common Input Computation:

All algorithms receive the same pre-computed error quantities:

// useControlAlgorithms.ts — compute() dispatcher
const compute = (goalX, goalY, robotX, robotY, robotTheta, dt) => {
  const dx    = goalX - robotX
  const dy    = goalY - robotY
  const distance     = Math.hypot(dx, dy)
  const desiredAngle = Math.atan2(dy, dx)
  let   headingError = desiredAngle - robotTheta
  while (headingError >  Math.PI) headingError -= 2*Math.PI
  while (headingError < -Math.PI) headingError += 2*Math.PI

  switch (currentAlgorithm.value) {
    case 'proportional':  return computeProportional(dx, dy, distance, headingError)
    case 'pid':           return computePID(dx, dy, distance, headingError, dt)
    case 'pure_pursuit':  return computePurePursuit(dx, dy, distance, headingError)
    case 'state_machine': return computeStateMachine(dx, dy, distance, headingError)
  }
}

5.4.2 Scheme 1: Proportional Controller

Direct proportional mapping from error to velocity command:

Platform defaults: , ,  m/s,  rad/s.

const computeProportional = (dx, dy, distance, headingError) => {
  const p       = params.proportional
  const linear  = _clamp(p.kp_linear  * distance,
                         -maxLinearVel.value, maxLinearVel.value)
  const angular = _clamp(p.kp_angular * headingError,
                         -maxAngularVel.value, maxAngularVel.value)
  return { linear, angular, algorithm: 'proportional', timestamp: Date.now() }
}

5.4.3 Scheme 2: PID Controller

General PID Law:

Discrete-Time Implementation with Anti-Windup:

Platform defaults: , , , .

const computePID = (dx, dy, distance, headingError, dt) => {
  const p = params.pid

  // Angular PID
  pidState.integral_angular = _clamp(
    pidState.integral_angular + headingError * dt,
    -p.integral_limit, p.integral_limit)

  const d_angular = (headingError - pidState.prev_error_angular) / dt
  const angular   = _clamp(
    p.kp_angular * headingError +
    p.ki_angular * pidState.integral_angular +
    p.kd_angular * d_angular,
    -maxAngularVel.value, maxAngularVel.value)

  pidState.prev_error_angular = headingError

  // Linear PID (distance error)
  pidState.integral_linear = _clamp(
    pidState.integral_linear + distance * dt,
    -p.integral_limit, p.integral_limit)

  const d_linear = (distance - pidState.prev_error_linear) / dt
  const linear   = _clamp(
    p.kp_linear  * distance +
    p.ki_linear  * pidState.integral_linear +
    p.kd_linear  * d_linear,
    0, maxLinearVel.value)

  pidState.prev_error_linear = distance
  return { linear, angular, algorithm: 'pid', timestamp: Date.now() }
}

Anti-Windup Bound:

Without clamping, the integral accumulates during prolonged large errors (e.g. when the robot is blocked) and produces large overshoots when the path clears.

5.4.4 Scheme 3: Pure Pursuit (Goal-Seeking)

For single-goal navigation, Pure Pursuit uses the goal itself as the lookahead target. The steering curvature and angular velocity:

Platform default:  m (lookahead distance).

const computePurePursuit = (dx, dy, distance, headingError) => {
  const p      = params.pure_pursuit
  const kappa  = 2 * Math.sin(headingError) / p.lookahead_distance
  const linear = _clamp(p.kp_linear * distance,
                        0, maxLinearVel.value)
  const angular = _clamp(linear * kappa,
                         -maxAngularVel.value, maxAngularVel.value)
  return { linear, angular, algorithm: 'pure_pursuit', timestamp: Date.now() }
}

Note: this is the goal-seeking variant. The curved-path variant used by useFigure­EightNavigation selects a moving lookahead point on a precomputed trajectory — see Module 5.5 for that derivation.

5.4.5 Scheme 4: State Machine Controller

Explicit two-phase control: align first, then drive. This prevents forward motion while the heading error is large, eliminating the lateral deviation that proportional control produces when the robot initially faces away from the goal.

State Transitions:

// States: 'aligning' | 'driving' | 'arrived'
ALIGNING  --[|e_theta| < heading_tolerance (0.12 rad)]--> DRIVING
DRIVING   --[|e_theta| > heading_tolerance * 2       ]--> ALIGNING
DRIVING   --[d < arrival_tolerance (0.15 m)          ]--> ARRIVED
const computeStateMachine = (dx, dy, distance, headingError) => {
  const p = params.state_machine

  if (distance < p.arrival_tolerance)
    return { linear: 0, angular: 0, algorithm: 'state_machine',
             timestamp: Date.now() }

  if (Math.abs(headingError) > p.heading_tolerance) {
    // ALIGNING: rotate only
    const angular = _clamp(2.0 * headingError,
                            -maxAngularVel.value, maxAngularVel.value)
    return { linear: 0, angular, algorithm: 'state_machine',
             timestamp: Date.now() }
  }

  // DRIVING: forward + gentle heading correction
  const linear  = _clamp(0.6 * distance, 0, maxLinearVel.value)
  const angular = _clamp(1.5 * headingError,
                          -maxAngularVel.value, maxAngularVel.value)
  return { linear, angular, algorithm: 'state_machine', timestamp: Date.now() }
}

5.4.6 Controller Comparison

PropertyPPIDPure PursuitState Machine
Steady-state errorPossibleEliminatedPath-dependentLow
Oscillation riskLowMed. (tuning)LowLow
Requires precomputed pathNoNoNo (goal variant)No
Tuning parameters2722
Smooth trajectoryModerateModerateSmoothStepped
Heading alignmentThresholdThresholdCurvature-basedExplicit phase

5.4.7 Obstacle Avoidance Integration

useObstacleAvoidance runs orthogonally to the controller — its output overrides the controller when a threat is detected.

Safety Zones (default values from codebase):

const safetyZones = reactive<SafetyZones>({
  critical:  0.3,   // m — emergency stop / max avoidance
  warning:   0.7,   // m — avoidance algorithm activates
  detection: 2.0,   // m — obstacle tracking begins
})

5.4.7.1 Potential Fields

The proximity factor scales avoidance strength linearly between warning and critical:

5.4.7.2 Dynamic Window Approach (DWA)

The avoidance direction is selected by comparing left vs. right sector clearance from the LiDAR data:

// Scale factor proportional to distance from critical zone
const scaleFactor = Math.max(0,
  (minFront - safetyZones.critical) / safetyZones.warning)
const leftClear = leftObstacles.every(o => o.range > safetyZones.warning)
const omega     = leftClear ? scaleFactor * maxAngularVel
                            : -scaleFactor * maxAngularVel

5.4.7.3 Vector Field Histogram (VFH)

Builds a polar histogram of obstacle density. Steering command selects the valley sector closest to the goal bearing.

5.4.7.4 Emergency Stop

Publishes zero velocity immediately when safetyStatus === ’critical’. Increments metrics.emergencyStops counter.

5.4.8 Algorithm Comparison

useAlgorithmComparison.ts provides a benchmarking composable that runs two algorithms sequentially on the same goal set and records their performance metrics side by side for display on the /control page analytics panel.

// useAlgorithmComparison.ts
const runComparison = async (algorithms: ControlAlgorithm[], goals: Goal[]) => {
  const results: ComparisonResult[] = []
  for (const algo of algorithms) {
    controlAlgos.setAlgorithm(algo)
    analytics.startCollection()
    await runGoalSequence(goals)
    analytics.stopCollection()
    results.push({ algorithm: algo, stats: analytics.stats.value })
  }
  return results
}

Integration: Theory to Practice

The /control page wires useControlAlgorithms, useObstacle­Avoidance, useMobileRobot­Simulation, and useAnalytics­Engine together in a

single 10 Hz control loop. The obstacle avoidance result is checked first; if avoidance is needed, its angular velocity overrides the controller output. The final {linear, angular} pair is sent to either the simulation physics engine or the ROS 2 /cmd_vel topic depending on the connection mode.

Theoretical Design Choices

Why for proportional control? At 2.0 rad/s per radian of heading error, a 90° error produces the maximum angular velocity of  rad/s (clamped). This gives a natural settling time of approximately one second for heading corrections, matching the 10 Hz control frequency well.

Why integral_limit = 0.5? The integral contribution is capped so that  rad/s, which is one-third of . This allows the integral to correct persistent errors without dominating the controller output during transient large errors.

Why heading_tolerance = 0.12 rad for the State Machine? rad is approximately 7°. This is tight enough to prevent visible lateral deviation during driving ( cm at 0.4 m/s for 0.1 s), while loose enough to avoid hunting around the exact heading in the alignment phase.

Tune the four controllers live
Proportional / PID / Pure Pursuit / State Machine — all selectable from the control panel.
/material-handling