오차·P 제어와 PID
95분 안팎
학습 목표
일차 속도 모델에서 P와 PID 응답 및 적분 포화를 비교합니다.
개념
응답 그래프 전에 모델을 고정합니다
목표점 근처에서 흔들린다는 보고만으로 PID 이득을 올리면 문제를 더 키울 수 있습니다. 위치 제어, 바퀴 속도 제어, 측정 지연이 각각 다른 층이기 때문입니다. 이번 레슨은 일차 속도 모델 하나를 고정해 P와 PID의 오차·포화·적분 상태를 비교합니다. 브라우저에서는 일정 목표를 유지하고 로컬 비교표에서는 2초 뒤 목표를 0으로 바꿉니다. 같은 모델에서 실험 조건을 분명히 해야 차이를 해석할 수 있습니다.
일차 속도 모델이 뜻하는 것
속도 y는 출력 u를 즉시 따라가지 않고 시간상수 tau=0.5초로 접근한다고 가정합니다. 이산식은 y_next=y+dt·(u−y)/0.5입니다. u와 y는 이 실험에서 동일한 정규화 속도 척도를 사용합니다. 실제 전압이나 rad/s로 보정된 모터 모델이라고 주장하지 않습니다. 목표 0.6과 출력 한계 1.0은 모델의 숫자이며 목표점 도달 반경 0.10m와는 서로 다른 물리량입니다.
P가 남기는 오차를 계산합니다
오차 e는 목표 r에서 현재 y를 뺀 값이고 P 출력은 kp·e입니다. 출력 제한이 없는 정상상태에서 y=u이므로 P만 사용하면 y=kp·r/(1+kp)가 됩니다. kp=1, r=0.6이면 y는 0.3으로 접근합니다. 이 모델에는 속도를 유지할 입력이 필요하므로 P만으로 오차가 남습니다. 반면 위치 적분기에서는 다른 정상상태 관계가 나오므로 모든 로봇 제어에 같은 수치를 적용하지 않습니다.
I는 오차의 시간을 기억합니다
누적값 integral에 e·dt를 더하고 ki를 곱합니다. dt 없이 오차만 더하면 같은 초 동안 반복 횟수가 많을수록 더 강한 적분기가 됩니다. 양의 오차가 오래 남으면 적분이 양의 출력을 만들어 P의 잔여 오차를 줄일 수 있습니다. 목표 변경이나 새 작업에서는 적분 상태를 어떤 정책으로 초기화할지도 정해야 합니다. 이 브라우저의 각 행은 새 제어기이므로 integral=0부터 시작합니다.
D는 측정 변화에 반응합니다
D는 −kd·(y−previous)/dt로 계산합니다. 첫 표본은 과거 측정이 없으므로 D=0으로 둡니다. 목표가 바뀌었을 때 목표 차이를 직접 미분하는 킥을 피하기 위해 측정값을 사용합니다. 그렇다고 측정 잡음이 제거되는 것은 아닙니다. 짧은 dt에서 작은 잡음도 큰 미분값이 될 수 있습니다. 이번 결정적 모델에는 잡음 필터가 없으므로 실물에 바로 같은 kd를 넣지 않습니다.
포화와 적분 폭주를 나눠 봅니다
raw=kp·e+ki·integral+D를 먼저 구하고 출력 u를 −limit에서 limit 사이로 제한합니다. raw가 한계를 넘은 표본을 saturation으로 셉니다. 출력만 잘라도 integral이 계속 쌓일 수 있습니다. 목표가 나중에 내려가면 쌓인 양의 적분 때문에 한동안 양의 출력이 남는 현상이 와인드업입니다. 제한된 출력과 제한되지 않은 내부 상태를 각각 기록해야 이 현상을 발견할 수 있습니다.
이번 보호 방식은 적분 크기 제한입니다
protect=1이면 적분을 −imax에서 imax 사이로 자릅니다. protect=0이면 적분을 자르지 않지만 출력 제한은 두 경우 모두 유지합니다. 이 방법은 내부 상태가 너무 커지는 것을 막는 단순한 보호이며 포화 해제에 최적인 방식이라고 보장하지 않습니다. 조건부 적분이나 역계산은 추가 설계 선택입니다. imax=0이면 I 상태가 0으로 유지되므로 ki가 있어도 실제 I 기여는 없습니다.
업데이트 순서를 바꾸지 않습니다
한 tick은 현재 측정으로 오차 계산, 적분 누적과 제한, 측정 미분, raw 계산과 출력 제한, 이전 측정 저장, 플랜트 갱신 순서입니다. previous에 새 y를 먼저 저장하면 D가 항상 0이 되는 오류가 생깁니다. integral 제한을 raw 계산 뒤에 적용하면 이번 출력에는 제한 전 값이 섞입니다. 코드 리뷰에서는 식만 보지 말고 상태를 읽고 쓰는 시점을 순서대로 표시합니다.
이득과 주기는 함께 시험합니다
kp를 올리면 보통 반응이 빨라지지만 샘플링과 지연 조건에 따라 진동할 수 있습니다. 로컬 실험은 kp=1과 4, dt=0.05와 0.20의 네 조합을 같은 초기 상태와 8초 구간으로 비교합니다. ki=1, kd=0.02와 목표 변경은 고정합니다. 한 번에 바꾸는 항목을 제한하고, 주기가 바뀌면 같은 시간에 대응하는 표본 수를 조정합니다. 모든 이득에 통하는 안정성 보장을 이 표로 대신하지 않습니다.
숫자 세 개의 의미를 읽습니다
브라우저 결과는 마지막 플랜트 속도, 마지막 적분 상태, 포화 표본 수입니다. 모두 오차라고 부르지 않습니다. 앞 두 값은 소수점 여섯 자리이고 마지막은 정수입니다. 0회 갱신은 0.000000 0.000000 0이며 첫 D도 0입니다. 입력은 kp ki kd dt n target limit imax protect 순서입니다. 이득은 0 이상, dt는 0 초과 0.5 이하, n은 정수 0~10000, limit는 양수, imax는 0 이상, protect는 0 또는 1입니다.
오류와 흔들림을 구별합니다
입력 숫자는 유한하고 절댓값 100 이하입니다. 형식이 다르거나 dt=0이면 ERROR입니다. ERROR는 진동을 관측한 결과가 아니라 계약 거절입니다. 반대로 속도가 표본마다 번갈아 바뀌는데 예외가 없다면 수치 응답을 읽어야 합니다. 출력 제한, dt, 이득을 확인하고 플랜트 상태 이력을 남깁니다. 나눗셈 오류를 피하려고 dt를 임의로 1로 바꾸면 요구한 실험을 다른 것으로 바꾸게 됩니다.
보고서는 목표 변경 뒤까지 봅니다
도달 속도 하나만 제출하면 포화 중 쌓인 적분이 해제 후 어떤 영향을 주는지 알 수 없습니다. 로컬 표의 settled는 목표를 0으로 바꾼 2초 이후 남은 표본이 모두 ±0.05 안에 있는 첫 시각입니다. None이면 8초 관측 안에 정착하지 않았습니다. 부호 전환 수는 오차 0인 표본을 제외하고 셉니다. 더 읽기는 PID 항의 원리로 이어가며 공식 자료의 포화와 와인드업 설명도 참고하되 이 레슨의 식과 수치를 별도로 검증합니다.
사실 확인 참고: MathWorks 공식 와인드업 설명은 포화와 적분 누적의 관계 및 보호 방식을 설명합니다.
따라하기
P만의 평형을 봅니다
이 일차 모델의 목표 0.6에서 kp=1의 8초 응답을 계산합니다.
y=0.0
for _ in range(160):
u=1.0*(0.6-y)
y+=0.05*(u-y)/0.5
print(f"speed={y:.6f} error={0.6-y:.6f}")실행 결과
speed=0.300000 error=0.300000
PID 주기를 비교합니다
동일 목표 변경과 8초 구간에서 이득·주기 조합을 비교합니다. error는 최종 목표 0과 속도의 차이입니다.
def simulate(kp,ki,kd,dt,steps,target=0.6,limit=1.0,imax=0.5,protect=True):
# First-order speed plant: dy/dt = (u-y)/tau, tau=0.5 s.
y=integral=previous=0.0
rows=[]
for k in range(steps):
r=target if k*dt<2.0 else 0.0
e=r-y
integral+=e*dt
if protect: integral=max(-imax,min(imax,integral))
d=0.0 if k==0 else -(y-previous)/dt
raw=kp*e+ki*integral+kd*d
u=max(-limit,min(limit,raw))
previous=y
y+=dt*(u-y)/0.5
rows.append((round((k+1)*dt,10),r-y,u,integral,abs(raw)>limit))
return rows
def metrics(rows,tolerance=0.05):
errors=[r[1] for r in rows]
signs=[1 if e>1e-9 else -1 if e< -1e-9 else 0 for e in errors]
nonzero=[s for s in signs if s]
flips=sum(a!=b for a,b in zip(nonzero,nonzero[1:]))
settled=None
for i,r in enumerate(rows):
if r[0]>=2.0 and all(abs(e)<=tolerance for e in errors[i:]):
settled=r[0];break
return dict(error=errors[-1],flips=flips,settled=settled,saturated=sum(r[4] for r in rows),peak_i=max(abs(r[3]) for r in rows))
for kp in (1.0,4.0):
for dt in (0.05,0.20):
m=metrics(simulate(kp,1.0,0.02,dt,round(8/dt)))
print(f"kp={kp:.1f} dt={dt:.2f} error={m['error']:.6f} flips={m['flips']} settled={m['settled']} saturation={m['saturated']}")실행 결과
kp=1.0 dt=0.05 error=-0.006168 flips=1 settled=4.4 saturation=0 kp=1.0 dt=0.20 error=-0.006588 flips=1 settled=4.4 saturation=0 kp=4.0 dt=0.05 error=-0.011751 flips=1 settled=2.35 saturation=7 kp=4.0 dt=0.20 error=-0.251954 flips=39 settled=None saturation=15
적분 제한을 비교합니다
처음 2초 목표 3은 출력 한계 1로 도달 불가능합니다. 목표 해제 뒤까지 내부 적분과 잔여 오차를 비교합니다.
def simulate(kp,ki,kd,dt,steps,target=0.6,limit=1.0,imax=0.5,protect=True):
# First-order speed plant: dy/dt = (u-y)/tau, tau=0.5 s.
y=integral=previous=0.0
rows=[]
for k in range(steps):
r=target if k*dt<2.0 else 0.0
e=r-y
integral+=e*dt
if protect: integral=max(-imax,min(imax,integral))
d=0.0 if k==0 else -(y-previous)/dt
raw=kp*e+ki*integral+kd*d
u=max(-limit,min(limit,raw))
previous=y
y+=dt*(u-y)/0.5
rows.append((round((k+1)*dt,10),r-y,u,integral,abs(raw)>limit))
return rows
def metrics(rows,tolerance=0.05):
errors=[r[1] for r in rows]
signs=[1 if e>1e-9 else -1 if e< -1e-9 else 0 for e in errors]
nonzero=[s for s in signs if s]
flips=sum(a!=b for a,b in zip(nonzero,nonzero[1:]))
settled=None
for i,r in enumerate(rows):
if r[0]>=2.0 and all(abs(e)<=tolerance for e in errors[i:]):
settled=r[0];break
return dict(error=errors[-1],flips=flips,settled=settled,saturated=sum(r[4] for r in rows),peak_i=max(abs(r[3]) for r in rows))
for protect in (False,True):
m=metrics(simulate(2,3,0.02,0.05,160,target=3,protect=protect))
print(f"protect={protect} peak_i={m['peak_i']:.6f} saturation={m['saturated']} error={m['error']:.6f}")실행 결과
protect=False peak_i=4.492610 saturation=110 error=-0.058803 protect=True peak_i=0.500000 saturation=40 error=-0.000307
확인 문제
실습
한 행 kp ki kd dt n target limit imax protect를 받습니다. 유한 실수들은 절댓값 100 이내입니다. 이득은 0 이상, dt는 0 초과 0.5 이하, n은 정수 0~10000, limit는 양수, imax는 0 이상, protect는 정수 0 또는 1입니다. 각 행은 y=integral=previous=0으로 시작합니다. response를 완성합니다. 목표는 n회 내내 일정하며 첫 D는 0입니다. 적분 갱신→선택적 적분 제한→측정 미분→출력 제한→플랜트 갱신 순서를 지킵니다. tau=0.5초입니다. 마지막 y·integral을 여섯 자리, raw의 절댓값이 limit보다 큰 표본 수를 정수로 출력합니다. 오류는 ERROR, 빈 파일은 출력 없음입니다.
모범 답안
import sys, math
def response(kp,ki,kd,dt,n,target,limit,imax,protect):
y=integral=previous=0.0;saturated=0
for k in range(n):
integral+=(target-y)*dt
if protect: integral=max(-imax,min(imax,integral))
derivative=0.0 if k==0 else -(y-previous)/dt
raw=kp*(target-y)+ki*integral+kd*derivative
saturated+=abs(raw)>limit
u=max(-limit,min(limit,raw))
previous=y
y+=dt*(u-y)/0.5
return y,integral,saturated
for line in sys.stdin:
try:
p=line.split()
if len(p)!=9: raise ValueError()
kp,ki,kd,dt=map(float,p[:4]);n=int(p[4]);target,limit,imax=map(float,p[5:8]);protect=int(p[8])
if not all(math.isfinite(a) and abs(a)<=100 for a in (kp,ki,kd,dt,target,limit,imax)): raise ValueError()
if min(kp,ki,kd)<0 or not 0<dt<=0.5 or not 0<=n<=10000 or limit<=0 or imax<0 or protect not in (0,1): raise ValueError()
y,i,s=response(kp,ki,kd,dt,n,target,limit,imax,protect)
print(f"{(0.0 if abs(y)<0.0000005 else y):.6f} {(0.0 if abs(i)<0.0000005 else i):.6f} {s}")
except (ValueError,OverflowError): print("ERROR")
더 읽기
면접 질문
- 목표점에 가까워져도 로봇이 흔들리는 상황을 설명해 주시면 됩니다.