Devin.KR

PID 제어 - 원하는 속도 유지하기

개발자KR 조회 4

이 장에서 배우는 것

앞 장에서는 PWM(pulse width modulation) 듀티로 DC 모터의 회전 속도를 조절하는 방법을 다뤘다. 그런데 듀티를 하나의 값으로 고정해 두면 배터리 전압이 내려가거나 바닥 마찰이 달라질 때마다 실제 바퀴 속도가 흔들린다. 이 장은 목표 속도와 엔코더로 잰 실제 속도를 계속 비교해 듀티를 스스로 조정하는 PID(비례-적분-미분, proportional-integral-derivative) 제어를 다룬다. P, I, D 각 항이 하는 역할을 구분하고, 게인을 튜닝하는 순서와 적분이 지나치게 쌓이는 문제(와인드업, windup)를 막는 방법까지 코드로 확인한다.

  • P, I, D 각 항이 속도 응답에 미치는 효과를 구분한다
  • 게인을 P → D → I 순서로 조정해야 하는 이유를 이해한다
  • 적분 와인드업이 왜 큰 오버슈트를 만드는지, 어떻게 막는지 코드로 구현한다
  • 단순화한 모터 모델로 PID 응답을 손으로도 검산해 본다

문제 상황

평평한 바닥에서 PWM 듀티 0.5로 30rad/s가 나오던 로봇이 카펫 위로 올라가면 부하가 늘어 같은 듀티로도 25rad/s밖에 나오지 않는다. 앞 장 방식대로 듀티를 목표 속도에 맞춰 한 번 계산해 고정해 버리면, 바닥이 바뀌어 생긴 이 오차를 로봇은 영영 알아채지 못한다. 목표 속도와 엔코더로 측정한 실제 속도를 비교해서 듀티를 계속 보정해야 하는데, 여기서 두 가지 문제가 생긴다.

첫째, 오차에 단순히 비례해서만 듀티를 보정하면(비례 제어만 쓰면) 부하가 걸린 상태에서 오차가 완전히 사라지지 않고 일정한 값으로 남는다(정상상태 오차). 둘째, 오차를 없애려고 오차를 계속 더해 나가는 항(적분)을 추가하면, 로봇이 벽에 눌려 멈춰 있거나 가속 초반처럼 목표에 한동안 도달할 수 없는 상황에서 이 누적값이 터무니없이 커져 버린다. 그 뒤 상황이 풀리면 쌓인 값 때문에 목표를 크게 지나친 뒤에야 돌아오는 큰 오버슈트가 생긴다. 이 장은 이 두 문제를 P·I·D 세 항의 역할과 안티와인드업(anti-windup)으로 해결한다.

PID 제어의 구조

PID 제어기는 목표값 r과 측정값 y의 차이인 오차 e = r − y를 입력으로 받아, 세 항을 더해서 출력을 만든다. 이산 시간으로는 스텝 k마다 다음과 같이 계산한다.

  • P항: Kp·e[k] — 지금 이 순간의 오차 크기에 비례해서 바로 반응한다. 게인 Kp가 작으면 반응이 느리고 정상상태 오차가 크게 남고, 너무 크면 진동한다.
  • I항: Ki·Σ(e[k]·dt) — 과거 오차를 계속 더해 나간다. 아무리 작은 오차라도 시간이 지나면 쌓여서 결국 오차를 0으로 만든다. 대신 쌓이는 속도가 빠르면 오버슈트와 진동을 유발한다.
  • D항: Kd·(−(y[k]−y[k−1])/dt) — 측정값이 변하는 속도(기울기)에 반응해 미리 제동을 건다. 진동을 억누르지만 측정 잡음을 그대로 미분하면 출력이 심하게 떨린다.
이산 시간에서 P·I·D 각 항이 계산하는 것
항이산화한 식반응하는 대상
PKp·e[k]지금 이 순간의 오차 크기
IKi·Σ(e[k]·dt)오차가 쌓여 온 이력
DKd·(−(y[k]−y[k−1])/dt)측정값이 변하는 속도
오차를 P·I·D 세 항으로 계산해 합산한 뒤 PWM으로 모터를 구동하고 측정 속도를 다시 오차 계산에 피드백한다

세 항은 각자 역할이 다르지만 동시에 서로의 부작용을 키울 수도 있다. 그래서 실무에서는 세 게인을 한꺼번에 바꾸지 않고 정해진 순서를 따라 하나씩 조정한다.

튜닝 순서

PID 게인을 조정하는 순서는 P → D → I가 표준적이다. 이유는 단순하다. 세 게인을 동시에 바꾸면 응답이 좋아지거나 나빠져도 어느 게인이 원인인지 구분할 수 없기 때문이다. 각 단계에서 하나의 게인만 바꾸면 원인과 결과를 명확히 연결할 수 있다.

게인을 조정하는 순서와 멈추는 기준
순서조정 게인목표멈추는 기준
1Kp목표에 빠르게 근접진동이 막 시작되기 직전
2Kd오버슈트·진동 억제오버슈트가 허용 범위로 줄어들 때
3Ki정상상태 오차 제거오차가 0으로 수렴하되 새 진동이 없을 때

Ki는 항상 가장 마지막에, 가장 작은 값부터 올린다. Ki를 먼저 크게 넣으면 P만으로도 생기던 진동에 적분의 누적 효과까지 겹쳐서 원인을 구분하기 어려워진다.

적분 와인드업과 안티와인드업

모터는 PWM 듀티를 무한히 크게 걸 수 없다. 듀티는 항상 −1.0에서 1.0 사이(또는 0에서 1.0 사이)로 제한된다. 문제는 PID 출력이 이 한계에 막혀 있는 동안에도 오차가 계속 존재하면 I항의 누적값(적분)은 멈추지 않고 계속 커진다는 점이다. 이 상태에서 오차의 부호가 반대로 바뀌어도, 쌓인 적분값이 워낙 크기 때문에 출력은 한동안 여전히 같은 방향으로 클램프(clamp)된 채 남아 있다. 그 결과 목표를 크게 지나친 뒤에야 되돌아오는 큰 오버슈트가 생긴다. 이것이 적분 와인드업이다.

가장 간단한 대응은 출력이 포화된 동안에는 적분을 갱신하지 않는 클램핑 방식이다. 이번 출력을 계산해 보고 그 값이 액추에이터 한계를 벗어난다면, 이번 스텝의 오차는 적분에 더하지 않는다. 다른 방법으로 back-calculation 기법도 있지만, 구현이 간단하고 효과가 분명한 클램핑 방식만 이 장에서 다룬다.

완성 코드

다음 코드를 pid_speed_control.py로 저장한다. 실제 모터의 전기·기계적 지연이나 엔코더 잡음까지 넣으면 계산이 복잡해지므로, 여기서는 모터를 "PWM 출력에 비례해 속도가 늘어나는 단순 적분기"로 근사한다. 이렇게 단순화하면 손으로도 각 단계의 값을 검산할 수 있다.

"""PID 속도 제어 시뮬레이터.

모터를 'PWM 출력에 비례해 속도가 늘어나는 단순 적분기'로 근사해
P, PI, PID 게인과 안티와인드업의 효과를 숫자로 확인한다.
"""
import numpy as np

DT = 1.0
ACCEL_GAIN = 2.0
PWM_MIN, PWM_MAX = -1.0, 1.0


class PIDController:
    def __init__(self, kp, ki, kd, dt, out_min, out_max, anti_windup=True):
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.dt = dt
        self.out_min = out_min
        self.out_max = out_max
        self.anti_windup = anti_windup
        self.integral = 0.0
        self.prev_measurement = 0.0
        self.has_prev = False

    def update(self, setpoint, measurement):
        error = setpoint - measurement
        p_term = self.kp * error

        if self.has_prev:
            derivative = -(measurement - self.prev_measurement) / self.dt
        else:
            derivative = 0.0
        self.has_prev = True
        self.prev_measurement = measurement
        d_term = self.kd * derivative

        tentative_integral = self.integral + error * self.dt
        i_term = self.ki * tentative_integral
        unclamped = p_term + i_term + d_term
        saturated = not (self.out_min <= unclamped <= self.out_max)

        if self.anti_windup and saturated:
            i_term = self.ki * self.integral
        else:
            self.integral = tentative_integral

        output = p_term + i_term + d_term
        return max(self.out_min, min(self.out_max, output))


def run_plant(pid, target, steps, drag_from=None, drag_size=1.0):
    speed = 0.0
    history = np.zeros(steps + 1)
    history[0] = speed
    for k in range(steps):
        pwm = pid.update(target, speed)
        drag = drag_size if drag_from is not None and k >= drag_from else 0.0
        speed = speed + ACCEL_GAIN * pwm - drag
        history[k + 1] = speed
    return history


def main():
    print("=== 부하 인가 시 P 단독 vs PI 비교 (목표 10.0, 스텝 6부터 부하) ===")
    pid_p = PIDController(kp=0.5, ki=0.0, kd=0.0, dt=DT, out_min=PWM_MIN, out_max=PWM_MAX)
    pid_pi = PIDController(kp=0.5, ki=0.2, kd=0.0, dt=DT, out_min=PWM_MIN, out_max=PWM_MAX)
    hist_p = run_plant(pid_p, target=10.0, steps=12, drag_from=6)
    hist_pi = run_plant(pid_pi, target=10.0, steps=12, drag_from=6)

    print(f"{'스텝':>4} {'P 단독':>10} {'PI':>10}")
    for k in (6, 7, 8, 9, 10, 11, 12):
        print(f"{k:>4} {hist_p[k]:>10.3f} {hist_pi[k]:>10.3f}")

    print()
    print("=== 큰 오차로 포화될 때 안티와인드업 유무 비교 (목표 10.0) ===")
    pid_safe = PIDController(kp=0.5, ki=0.5, kd=0.0, dt=DT, out_min=PWM_MIN, out_max=PWM_MAX,
                              anti_windup=True)
    pid_windup = PIDController(kp=0.5, ki=0.5, kd=0.0, dt=DT, out_min=PWM_MIN, out_max=PWM_MAX,
                                anti_windup=False)
    hist_safe = run_plant(pid_safe, target=10.0, steps=16)
    hist_windup = run_plant(pid_windup, target=10.0, steps=16)

    print(f"{'스텝':>4} {'안티와인드업':>12} {'와인드업(고장)':>14}")
    for k in (5, 9, 10, 13, 16):
        print(f"{k:>4} {hist_safe[k]:>12.3f} {hist_windup[k]:>14.3f}")


if __name__ == "__main__":
    main()

줄별 해설

  • 상수부: DT는 한 스텝의 시간 간격, ACCEL_GAIN은 PWM 듀티 1.0이 한 스텝 동안 속도를 얼마나 늘리는지를 나타내는 단순화한 계수다. PWM_MIN/MAX는 액추에이터가 낼 수 있는 듀티 범위다.
  • PIDController.__init__: 게인(kp, ki, kd)과 제어 주기(dt), 출력 한계(out_min, out_max), 안티와인드업 사용 여부(anti_windup)를 저장한다. integral은 누적 오차, prev_measurement는 미분 계산에 쓸 이전 측정값이다.
  • update()의 P·D 계산: 오차는 목표에서 측정값을 뺀 값이다. 미분은 오차가 아니라 측정값의 변화율로 계산한다(derivative on measurement). 이렇게 하면 목표값이 갑자기 바뀌어도 D항이 스파이크를 만들지 않는다. 이유는 이어지는 "실무에서 자주 틀리는 것"에서 다룬다.
  • update()의 안티와인드업 분기: 일단 적분을 더해 본(tentative_integral) 뒤 출력이 한계를 벗어나는지(saturated) 확인한다. 벗어났고 anti_windup이 켜져 있으면 이번 스텝의 적분 갱신을 취소하고 이전 값을 그대로 쓴다. 그렇지 않으면 적분을 실제로 갱신한다.
  • run_plant(): 매 스텝 PID 출력을 구해 속도를 업데이트하는 단순화한 모터 모델이다. drag_from 이후로는 drag_size만큼 속도가 깎여, 바닥이 바뀌어 부하가 늘어난 상황을 흉내 낸다.
  • main()의 첫 번째 비교: P 단독과 PI를 같은 부하 조건에서 비교해 정상상태 오차 제거 효과를 보여준다.
  • main()의 두 번째 비교: 같은 PI 게인에 anti_windup만 켜고 끈 두 컨트롤러를 비교해 와인드업이 만드는 오버슈트를 보여준다.

실행 결과

$ python3 pid_speed_control.py
=== 부하 인가 시 P 단독 vs PI 비교 (목표 10.0, 스텝 6부터 부하) ===
  스텝       P 단독         PI
   6     10.000     10.000
   7      9.000      9.000
   8      9.000      9.400
   9      9.000      9.640
  10      9.000      9.784
  11      9.000      9.870
  12      9.000      9.922

=== 큰 오차로 포화될 때 안티와인드업 유무 비교 (목표 10.0) ===
  스텝       안티와인드업       와인드업(고장)
   5       10.000         10.000
   9       10.000         18.000
  10       10.000         20.000
  13       10.000         14.000
  16       10.000          8.000

P 단독은 부하가 걸린 뒤 9.000에서 멈춰 오차 1.0을 영영 없애지 못하지만, PI는 스텝이 지날수록 9.4, 9.64, 9.784로 목표 10.0에 점점 가까워진다. 두 번째 표에서는 안티와인드업을 켠 컨트롤러가 목표 10.0에 도달한 뒤 그대로 안정되는 반면, 꺼 둔 컨트롤러는 목표의 두 배인 20.0까지 치솟았다가 8.0까지 떨어지는 큰 진동을 보인다.

안티와인드업이 없으면 목표의 두 배까지 속도가 치솟지만 안티와인드업을 적용하면 목표에서 바로 안정된다

실무에서 자주 틀리는 것

적분을 무한히 쌓아서 와인드업이 생긴다

출력이 포화됐는지 확인하지 않고 매 스텝 적분을 갱신하면, 큰 오차가 오래 지속되는 상황(가속 초반, 장애물에 막힌 상황)에서 적분값이 감당할 수 없이 커진다.

# 틀린 코드
def update(self, setpoint, measurement):
    error = setpoint - measurement
    self.integral += error * self.dt
    output = self.kp * error + self.ki * self.integral
    return max(self.out_min, min(self.out_max, output))
# 고친 코드
tentative_integral = self.integral + error * self.dt
i_term = self.ki * tentative_integral
unclamped = p_term + i_term + d_term
saturated = not (self.out_min <= unclamped <= self.out_max)
if self.anti_windup and saturated:
    i_term = self.ki * self.integral
else:
    self.integral = tentative_integral

목표값을 그대로 미분해 설정값 킥(setpoint kick)이 생긴다

오차 e = 목표 − 측정값을 미분하면, 목표가 갑자기 바뀌는 순간 오차가 계단 형태로 튀면서 미분값도 순간적으로 크게 튄다. 이 스파이크가 D항을 통해 그대로 출력에 반영되면 모터에 순간적으로 큰 듀티가 걸린다.

# 틀린 코드
def update(self, setpoint, measurement):
    error = setpoint - measurement
    derivative = (error - self.prev_error) / self.dt
    self.prev_error = error
    # ...
# 고친 코드 (measurement만 미분한다)
if self.has_prev:
    derivative = -(measurement - self.prev_measurement) / self.dt
else:
    derivative = 0.0
self.prev_measurement = measurement

PID 출력을 액추에이터 한계로 자르지 않는다

PID 계산 결과를 그대로 듀티로 흘려보내면, 게인이 조금만 커도 앞 장에서 다룬 PWM 유효 범위를 벗어난 값이 액추에이터에 전달된다.

# 틀린 코드
pwm = pid.update(target_speed, measured_speed)
motor.set_duty(pwm)  # PWM 유효 범위를 벗어날 수 있다
# 고친 코드
pwm = pid.update(target_speed, measured_speed)
pwm = max(-1.0, min(1.0, pwm))
motor.set_duty(pwm)

실제 루프 주기를 재지 않고 dt를 상수로 고정한다

로깅이나 통신 때문에 루프 한 바퀴가 예상보다 오래 걸리면, 코드에 적어 둔 dt와 실제 경과 시간이 어긋나 적분·미분 계산이 실제 시간과 맞지 않는다.

# 틀린 코드
DT = 0.01
while True:
    pwm = pid.update(target, read_speed())
    set_duty(pwm)
    time.sleep(0.01)  # 로깅 지연으로 실제 주기는 더 길어질 수 있다
# 고친 코드
last_time = time.time()
while True:
    now = time.time()
    dt = now - last_time
    last_time = now
    pid.dt = dt
    pwm = pid.update(target, read_speed())
    set_duty(pwm)

한눈에 보기

게인과 안티와인드업의 효과 요약
요소주요 효과과하면 생기는 문제조정 시점
P즉각 반응, 목표에 빠르게 접근진동·오버슈트가장 먼저 올린다
D변화율에 반응해 진동 억제측정 잡음 증폭P 다음에 소량 추가
I누적 오차 제거(정상상태 오차 0)와인드업·큰 오버슈트마지막에 소량부터
안티와인드업포화 중 적분 갱신 정지없으면 I의 부작용이 심해짐I 도입과 동시에 적용

연습 문제

  1. 목표 속도를 유지하려는 로봇에 P 제어만 적용했더니, 평평한 바닥에서는 오차가 없다가 카펫 위로 올라가자 속도가 목표보다 항상 조금 낮게 유지됐다. 이 현상이 나타나는 이유를 P항의 수식을 근거로 설명하라.
  2. 완성 코드의 PIDController.update()에서 anti_windup 매개변수를 False로 바꿔 시뮬레이션했을 때 "실행 결과"의 수치가 어떻게 달라지는지, 왜 그런 차이가 생기는지 설명하라.
  3. update()가 오차가 아니라 측정값을 미분해 D항을 계산하는 이유를 설명하라. 만약 오차를 직접 미분했다면 목표 속도가 갑자기 30에서 10으로 바뀌는 순간 어떤 문제가 생기는가?
  4. 실무에서 게인을 튜닝할 때 P, I, D를 한 번에 여러 개씩 바꾸면 안 되는 이유를 튜닝 순서(P → D → I)와 연결해 설명하라.

정답과 해설

1. P항은 u = Kp·e로 그 순간의 오차에만 비례한다. 부하가 걸리면 같은 출력으로도 속도가 덜 나오므로, 가속이 0인 평형 상태가 되려면 출력이 부하를 버틸 만큼 커야 하고, 그러려면 오차가 어느 정도 남아 있어야 한다. 오차가 0이 되면 출력도 0이 되어 부하를 이기지 못하므로 정상상태에서는 오차가 0이 아닌 값에서 멈춘다. 본문 표에서 P 단독이 부하 후 9.000(오차 1.0)에 머무는 것이 이 현상이다.

2. anti_windup을 False로 두면 출력이 포화된 동안에도 적분이 계속 커진다(와인드업). 오차의 부호가 바뀐 뒤에도 한동안 큰 적분값 때문에 출력이 같은 방향으로 클램프된 채 남는다. 실행 결과에서 안티와인드업 버전은 목표 10.000에 도달한 뒤 계속 10.000을 유지하지만, 와인드업 버전은 20.000까지 치솟았다가 8.000까지 떨어지는 큰 진동을 보인다.

3. 오차 e = 목표 − 측정값을 미분하면 목표가 갑자기 바뀌는 순간 오차가 계단 형태로 튀고, 그 미분값도 순간적으로 매우 커진다(설정값 킥). 이 스파이크가 D항을 통해 그대로 출력에 반영되면 모터에 순간적으로 큰 듀티가 걸린다. 반면 측정값은 목표가 바뀌어도 물리적으로 연속적으로만 변하므로, 측정값을 미분하면 이런 순간적 스파이크가 생기지 않는다.

4. P, D, I는 서로 다른 문제를 해결한다. P는 반응 속도, D는 진동 억제, I는 정상상태 오차 제거를 담당하는데, 여러 게인을 동시에 바꾸면 응답이 좋아지거나 나빠져도 어느 게인이 원인인지 구분할 수 없다. P부터 진동 직전까지 올리고, D로 그 진동을 누르고, 마지막에 I로 남은 오차를 지우는 순서를 지켜야 각 단계에서 원인과 결과를 명확히 연결할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.