PID 제어 - 원하는 속도 유지하기
이 장에서 배우는 것
앞 장에서는 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 | Kp·e[k] | 지금 이 순간의 오차 크기 |
| I | Ki·Σ(e[k]·dt) | 오차가 쌓여 온 이력 |
| D | Kd·(−(y[k]−y[k−1])/dt) | 측정값이 변하는 속도 |
세 항은 각자 역할이 다르지만 동시에 서로의 부작용을 키울 수도 있다. 그래서 실무에서는 세 게인을 한꺼번에 바꾸지 않고 정해진 순서를 따라 하나씩 조정한다.
튜닝 순서
PID 게인을 조정하는 순서는 P → D → I가 표준적이다. 이유는 단순하다. 세 게인을 동시에 바꾸면 응답이 좋아지거나 나빠져도 어느 게인이 원인인지 구분할 수 없기 때문이다. 각 단계에서 하나의 게인만 바꾸면 원인과 결과를 명확히 연결할 수 있다.
| 순서 | 조정 게인 | 목표 | 멈추는 기준 |
|---|---|---|---|
| 1 | Kp | 목표에 빠르게 근접 | 진동이 막 시작되기 직전 |
| 2 | Kd | 오버슈트·진동 억제 | 오버슈트가 허용 범위로 줄어들 때 |
| 3 | Ki | 정상상태 오차 제거 | 오차가 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 도입과 동시에 적용 |
연습 문제
- 목표 속도를 유지하려는 로봇에 P 제어만 적용했더니, 평평한 바닥에서는 오차가 없다가 카펫 위로 올라가자 속도가 목표보다 항상 조금 낮게 유지됐다. 이 현상이 나타나는 이유를 P항의 수식을 근거로 설명하라.
- 완성 코드의
PIDController.update()에서 anti_windup 매개변수를 False로 바꿔 시뮬레이션했을 때 "실행 결과"의 수치가 어떻게 달라지는지, 왜 그런 차이가 생기는지 설명하라. update()가 오차가 아니라 측정값을 미분해 D항을 계산하는 이유를 설명하라. 만약 오차를 직접 미분했다면 목표 속도가 갑자기 30에서 10으로 바뀌는 순간 어떤 문제가 생기는가?- 실무에서 게인을 튜닝할 때 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로 남은 오차를 지우는 순서를 지켜야 각 단계에서 원인과 결과를 명확히 연결할 수 있다.