Skip to content

PID Controller Module

Configurable PID controller with anti-windup, output clamping, and YAML-based tuning, plus a per-axis (x/y/z/yaw) variant for position control. Used by the navigator's PID methods, and usable standalone for any control loop.

At a glance

from nectar.control.pid import PIDController

pid = PIDController(kp=1.0, ki=0.1, kd=0.05, setpoint=2.0)
output = pid.update(current_value)   # control output, clamped to output_limits

Concepts

A PIDController is configured by a PIDConfig (loadable from YAML); PositionPIDConfig bundles one PIDConfig per axis for x/y/z/yaw.

classDiagram
    class PIDController {
        +kp float
        +ki float
        +kd float
        +setpoint float
        +output_limits tuple~float,float~
        +integral_limits tuple~float,float~
        +output float
        -_integral float
        -_last_error float
        -_last_time Optional~float~
        -_first_update bool
        -_proportional float
        -_derivative float
        +update(current_value) float
        +reset()
        +set_setpoint(value)
        +tune(kp, ki, kd)
        +get_components() dict
    }

    class PIDConfig {
        <<dataclass>>
        +kp float
        +ki float
        +kd float
        +setpoint float
        +output_min float
        +output_max float
        +integral_min float
        +integral_max float
        +from_yaml(path)$ PIDConfig
        +from_dict(data)$ PIDConfig
        +to_dict() dict
        +get_output_limits() tuple~float,float~
        +get_integral_limits() tuple~float,float~
    }

    class PositionPIDConfig {
        <<dataclass>>
        +x PIDConfig
        +y PIDConfig
        +z PIDConfig
        +yaw PIDConfig
        +from_yaml(path) PositionPIDConfig
        +to_dict() dict
    }

    PIDController ..> PIDConfig : configured by
    PositionPIDConfig *-- PIDConfig

PIDController

Standard PID implementation with anti-windup and output clamping.

API

from nectar.control.pid import PIDController

pid = PIDController(
    kp: float = 0.0,                      # Proportional gain
    ki: float = 0.0,                      # Integral gain
    kd: float = 0.0,                      # Derivative gain
    setpoint: float = 0.0,                # Target value
    output_limits: tuple = (-1.0, 1.0),   # Output clamp
    integral_limits: tuple = (-1.0, 1.0), # Anti-windup
    output_deadband: float = 0.0          # Symmetric output deadband (0 disables)
)

Control loop

PIDController.update() implements a discrete-time PID with anti-windup on the integral term and clamping on the output:

\[ e_k = r - y_k \]
\[ P_k = K_p \, e_k \]
\[ I_k = \mathrm{clamp}\!\left(K_i \sum_{i=0}^{k} e_i \, \Delta t,\; I_{\min},\, I_{\max}\right) \]
\[ D_k = K_d \frac{e_k - e_{k-1}}{\Delta t} \]
\[ u_k = \mathrm{clamp}(P_k + I_k + D_k,\; u_{\min},\, u_{\max}) \]

Where \(r\) is the setpoint (setpoint), \(y_k\) is the current measurement, \(\Delta t\) is the elapsed time between calls (from time.time()), and \(\mathrm{clamp}(x, a, b) = \min(\max(x, a), b)\).

An optional output deadband (output_deadband) forces \(u_k = 0\) when \(|u_k| < \mathrm{deadband}\) after clamping.

control_output = pid.update(current_value: float) -> float

Methods

pid.update(current_value)              # Returns control output
pid.reset()                            # Clear integral, previous error
pid.set_setpoint(value)                # Change target
pid.tune(kp, ki, kd)                   # Update gains
pid.get_components()                   # Returns proportional, integral, derivative, output

PIDConfig

Configuration dataclass for single-axis PID.

from nectar.control.pid import PIDConfig

config = PIDConfig(
    kp=0.5,
    ki=0.0,
    kd=0.0,
    output_min=-0.42,
    output_max=0.42,
    integral_min=-0.5,
    integral_max=0.5
)

Loading from YAML

# pid_config.yaml
kp: 0.5
ki: 0.0
kd: 0.0
output_min: -0.42
output_max: 0.42
integral_min: -0.5
integral_max: 0.5
config = PIDConfig.from_yaml("pid_config.yaml")

Loading from Dictionary

config = PIDConfig.from_dict({
    "kp": 0.5,
    "output_min": -0.42,
    "output_max": 0.42
})

Default Values: Unspecified fields use defaults (ki=0.0, kd=0.0, etc.).

PositionPIDConfig

Multi-axis configuration for position control (X, Y, Z, yaw).

from nectar.control.pid import PositionPIDConfig, PIDConfig

config = PositionPIDConfig(
    x=PIDConfig(kp=0.5, output_min=-0.42, output_max=0.42),
    y=PIDConfig(kp=0.5, output_min=-0.42, output_max=0.42),
    z=PIDConfig(kp=0.22, output_min=-0.15, output_max=0.1),
    yaw=PIDConfig(kp=0.5, ki=0.1, output_min=-0.2, output_max=0.2)
)

YAML Format

# position_config.yaml
x:
  kp: 0.5
  ki: 0.0
  kd: 0.0
  output_min: -0.42
  output_max: 0.42
  integral_min: -0.5
  integral_max: 0.5

y:
  kp: 0.5
  output_min: -0.42
  output_max: 0.42

z:
  kp: 0.22
  output_min: -0.15
  output_max: 0.1

yaw:
  kp: 0.5
  ki: 0.1
  output_min: -0.2
  output_max: 0.2
  integral_min: -0.05
  integral_max: 0.05
config = PositionPIDConfig.from_yaml("position_config.yaml")

Usage in Drone Control

ArduPilotDrone Integration

PID controllers created per-axis from configuration:

# In VehicleNavigator.navigate_pid()
pid_x = self._create_pid("x")      # Creates from self._pid_config.x
pid_y = self._create_pid("y")
pid_z = self._create_pid("z")
pid_yaw = self._create_pid("yaw")

# Control loop
while True:
    dx, dy, dz, dyaw = self._compute_errors(target, yaw)

    vx = pid_x.update(-dx)
    vy = pid_y.update(-dy)
    vz = pid_z.update(-dz)
    vyaw = pid_yaw.update(-dyaw)

    drone.move_velocity(vx, vy, vz, vyaw)

Configuration Loading

Automatic — the config picks a preset by is_indoor:

Mode Preset loaded
Indoor (vision) ardupilot/config/position_indoor.yaml
Outdoor (GPS) ardupilot/config/position_outdoor.yaml
SITL position_sim_indoor.yaml / position_sim_outdoor.yaml
config = MavrosConfig(pose_source=PoseSource.VISION)
drone = DroneFactory.create("mavros", config)

Explicit:

config = MavrosConfig(
    pose_source=PoseSource.VISION,
    pid_config_file="/path/to/custom.yaml"
)

Runtime:

drone.set_pid_config("/path/to/config.yaml")
drone.set_pid_config(config_dict)
drone.set_pid_config(PositionPIDConfig(...))

Tuning Guidelines

Proportional Gain (kp)

Controls response magnitude.

  • Higher kp: Faster response, potential overshoot
  • Lower kp: Slower response, more stable

  • Indoor: 0.3-0.6 (vision pose is accurate)

  • Outdoor: 0.6-1.0 (GPS noise requires higher gain)

Integral Gain (ki)

Eliminates steady-state error.

  • Higher ki: Faster error elimination, potential instability
  • Lower ki: Slower convergence, more stable

  • Typical: 0.0-0.1 (often not needed for position control)

  • Yaw: 0.05-0.15 (helps with compass drift)

Derivative Gain (kd)

Dampens oscillations and overshoot.

  • Higher kd: More damping, sensitive to noise
  • Lower kd: Less damping, smoother response

Position Control: Usually 0.0 (velocity commands already provide damping)

Output Limits

Velocity command limits (m/s for position, rad/s for yaw).

  • Indoor: ±0.4-0.6 m/s (safe in constrained space)
  • Outdoor: ±0.8-1.5 m/s (more aggressive allowed)
  • Vertical: ±0.15-0.8 m/s (asymmetric: slower ascent)

Integral Limits

Anti-windup protection.

  • Typical: 10-20% of output limits
  • Purpose: prevent the integral term from accumulating during saturation

Default Configurations

Indoor (Vision-based)

x:
  kp: 0.5
  output_min: -0.42
  output_max: 0.42

y:
  kp: 0.5
  output_min: -0.42
  output_max: 0.42

z:
  kp: 0.22
  output_min: -0.15
  output_max: 0.1

yaw:
  kp: 0.5
  ki: 0.1
  output_min: -0.2
  output_max: 0.2

Rationale:

  • Lower velocities for safety indoors
  • Asymmetric Z limits (slower ascent to avoid ceiling collisions)
  • Yaw integral term compensates for vision pose drift

Outdoor (GPS-based)

x:
  kp: 0.8
  output_min: -1.0
  output_max: 1.0

y:
  kp: 0.8
  output_min: -1.0
  output_max: 1.0

z:
  kp: 0.5
  output_min: -0.8
  output_max: 0.8

yaw:
  kp: 0.5
  ki: 0.1
  output_min: -0.3
  output_max: 0.3

Rationale:

  • Higher gains compensate for GPS latency and noise
  • Larger velocity limits for faster waypoint transitions
  • Symmetric Z limits (open outdoor environment)

Examples

Basic PID Control

from nectar.control.pid import PIDController

altitude_pid = PIDController(
    kp=0.5,
    ki=0.1,
    kd=0.0,
    setpoint=10.0,
    output_limits=(-0.5, 0.5)
)

while True:
    current_altitude = get_altitude()
    vz = altitude_pid.update(current_altitude)
    set_velocity_z(vz)

Position Control with Configuration

from nectar.control.pid import PositionPIDConfig

config = PositionPIDConfig.from_yaml("ardupilot/config/position_outdoor.yaml")

pid_x = PIDController(
    kp=config.x.kp,
    ki=config.x.ki,
    kd=config.x.kd,
    output_limits=config.x.get_output_limits(),
    integral_limits=config.x.get_integral_limits()
)

Tuning During Flight

# Start with conservative gains
drone.set_pid_config({
    "x": {"kp": 0.3, "output_min": -0.3, "output_max": 0.3},
    "y": {"kp": 0.3, "output_min": -0.3, "output_max": 0.3},
    "z": {"kp": 0.2, "output_min": -0.2, "output_max": 0.2}
})

drone.move_to(x=2.0, y=0.0, z=0.0)

# Increase gains if response too slow
drone.set_pid_config({
    "x": {"kp": 0.6, "output_min": -0.5, "output_max": 0.5},
    "y": {"kp": 0.6, "output_min": -0.5, "output_max": 0.5}
})

drone.move_to(x=-2.0, y=0.0, z=0.0)