PID제어를 구현하는 과정에서 많은 시행착오와 데이터 통신 이슈로 인하여 프로젝트 일정이 늦어졌다.

 

모터의 속도를 정확하게 제어하는 것은 로봇의 Odometry를 구성하는데 꼭 필요하다.

모터의 정확한 속도를 기반으로 로봇이 어디까지 갔는지 위치를 추정할 수 있기 때문이다.

 

PID 제어

기본적으로 피드백 형태를 가지며 비례(Proportional), 적분(Integral), 미분(Differential) 제어를 이용하는 방법 중 하나이다.

구현이 쉽고 경제적 이점 때문에 다양한 분야에서 광범위하게 사용되고 있는 제어 기법이다.

위 식은 순서대로 비례항, 적분항, 미분항이 더해진 형태이다.

 

비례항은 오차값에 Kp 게인을 곱한다.

오차값이란 [목표 값 - 실제 출력되는 값]이다.

그래서 오차값의 크기에 비례하여 제어를 도와준다.

이는 곧 목표값에 빠르게 접근 가능하게 해 준다는 말이다.

하지만 비례제어만으로 목표값과 실제 출력되는 값의 오차를 0으로 만드는 것은 굉장히 어렵다.

그래서 적분제어의 개념이 필요하다.

 

적분항은 오차를 시간에 대해 적분하여 Ki 게인과 곱한다.

정상상태의 오차를 줄여주는데, 쉽게 말하면 비례제어로 잡을 수 없는 목푯값과 실제값의 편차를 상당히 줄여준다.

하지만 적분제어를 적용하면 오버슈팅이 더 커지고 정착시간이 길어지는 단점이 있다.

이 오버슈팅은 제어값 증감량이 순간적으로 증가하여 시스템에 좋지 못한 영향을 줄 수 있다.

이 단점을 보완하기 위해서 미분제어의 개념이 필요하다.

 

미분항은 오차의 변화율에 Kd 게인을 곱한다.

오차의 변화율을 계산하게 되는데, 오차의 변화율이 크다면 미분제어값이 커지고 오차의 변화율이 작다면 미분제어값이 작아진다.

이러한 특성은 적분제어를 적용했을 때 커지는 오버슛을 효과적으로 커버해줄 수 있게 된다.

갑자기 변화하는 값에 반대 값을 주어 PID제어의 값을 안정화하는 역할을 한다.

 

이렇게 세 가지 특성을 가지고 PID제어를 구성하게 된다.

 

Kp와 Ki, Kd 게인(파라미터) 튜닝

이 영역부터는 흔히 짬이 중요하다고 한다.

자동으로 게인값을 튜닝해 주는 기술도 있긴 하다만 보통은 제어값을 그래프로 보며 직접 튜닝하는 경우가 대다수다.

왜냐하면 실제로 관측되는 응답에 대해서 게인값을 개별적으로 조정하여 최적의 성능을 얻을 수 있기 때문이다.

따라서 완벽에 가까운 제어 시스템을 구현하기 위해서 짬이 중요하고 볼 수 있겠다.

 

위에서 말한 것처럼 PID제어는 P, I, D에 대한 게인값을 개별적으로 조정한다.

보통은 P -> I -> D 순으로 제어값 튜닝을 진행한다.

1. P(어느 정도 목푯값에 비슷하게 끌어올리고)

2. I(목푯값과 실제값의 편차를 줄이고)

3. D(오버슈팅과 정착시간을 개선) 

 

목표값과 실제값과 제어값을 그래프에 띄워두고 게인값을 변경해 가며 튜닝한다.

특히 각 제어 게인의 변경에 따라 달라지는 특성을 잘 파악해야 한다.

 

Kp 튜닝하기

Kp값이 커질 경우

  • 실제값이 목푯값에 더 빨리 도달하게 된다.
  • 오버슛이 증가한다.
  • 편차가 감소한다.
  • 불안정해진다.

Kp값이 작을 경우는 위와 반대로 생각하면 된다.

아래 gif는 각 제어 게인을 튜닝할 때의 그래프의 변화이다.

보면서 빠르게 이해가 가능하다.

출처https://ko.wikipedia.org/wiki/PID_%EC%A0%9C%EC%96%B4%EA%B8%B0

Ki 튜닝하기

Ki값이 커질 경우

  • 오버슛이 증가한다.
  • 목푯값과 실제값의 편차가 감소한다.
  • 정착시간이 증가한다.
  • 불안정해진다.

Ki값이 작을 때는 위와 반대로 생각하면 된다.

다시 gif이미지를 보며 이해해 보자.

출처https://ko.wikipedia.org/wiki/PID_%EC%A0%9C%EC%96%B4%EA%B8%B0

 

Kd 튜닝하기

Kd값이 커질 경우

  • 오버슛이 감소한다.
  • 시스템이 안정해진다.
  • 정착시간이 줄어든다.

Kd값이 작을 때는 위와 반대로 생각하면 된다.

다시 gif이미지를 보며 이해해 보자.

출처https://ko.wikipedia.org/wiki/PID_%EC%A0%9C%EC%96%B4%EA%B8%B0

메카넘휠 로봇의 PID 게인 튜닝

사용자가 원하는 입력을 완벽하게 수행해 주는 장치는 사실 세상에 존재하지 않는다.

사용 중인 모터를 예로 들면 내가 15 rpm을 명령하면 실제 엔코더를 이용해 분석한 속도는 20 rpm이 찍힌다.

모터에 7 rpm까지 명령해도 모터는 돌지 않는다.

하지만 PID제어를 적용하면 15 rpm에서도 15 rpm을 굉장히 근접하게 따라가고 모터에 1 rpm을 주어도 움직인다.

그래서 PID 전과 후는 하늘과 땅 차이라고 느낄 수도 있다.

 

위에 설명한 개념만 지키면 짬이 높지 않더라도 그럭저럭 괜찮은 제어 시스템을 구성해 볼 수 있다.

상황에 따라 PI, PD, PID와 같이 원하는 제어만 골라서 사용이 가능하다.

 

제어기는 아두이노 메가이다.

 

첫 번째로 P제어 게인값인 Kp를 튜닝해 주었다.

아두이노 그래프 플롯 기능으로 데이터를 선택해서 시각화해준다.

이후 Kp값을 0에서부터 증가시키며 적당한 오버슛과 어프로치를 가지는 지점을 선정하였다.

Kp값이 2.3을 넘어서면 시스템이 많이 불안정해졌다.

제어값이 불안정해지니 모터의 소음이 증가하였다.

실험적으로 선택한 적절한 Kp값은 1.15였다.

빨간색 : 실제값

파란색 : 목푯값

초록색 : 제어값

두 번째로 I제어 게인값인 Ki를 튜닝해 주었다.

이미 비례제어가 Kp [0.4]로 적용되어 PI제어로 넘어왔다고 볼 수 있다.

Kp가 0.8 이상 넘어가면 시스템이 상당히 불안정해져서 발산해 버린다.

정착시간과 오버슛을 감안하여 Ki의 적당한 값을 0.2로 설정하였다.

PI제어를 하여 실제 모터의 속도가 목표하는 속도를 지속적으로 따라가게 되었다.

빨간색 : 실제값

파란색 : 목푯값

초록색 : 제어값

세 번째로 D제어 게인값인 Kd를 튜닝해 주었다.

앞서 Kp [0.4], Ki [0.2]가 적용되어 비로소 PID제어라고 볼 수 있다.

Kd값이 1.0을 넘어서면 모터에 떨림이 굉장히 심해졌다.

Kd값을 크게 주지 않아도 되므로 적당히 안정한 값인 0.1로 설정하였다.

D제어를 추가하였지만 이상하게 오버슈팅이 크게 완화되지 않았다.

Kd 게인을 높일수록 시스템이 발산하려는 성질을 보였다.

그나마 최대한 맞춰본 게 이 정도이고 원인이 뭘까를 생각해 봤다.

적분제어를 적용했는데도 시스템이 이렇게 불안정한 이유를 생각해 보았다.

가장 유력한 원인은 제어를 적용하지 않았을 때의 모터의 목푯값과 실제값이 너무 큰 차이를 보이기 때문이 아닐까 생각했다.

P제어를 적용하여 목푯값을 넣어줘도 RPM값이 올라갈수록 그 편차가 더욱 심해졌었다.

 

PID제어를 적용한다고 하더라도 모터는 원래 그랬던 것처럼 순간적으로 목푯값보다 높은 속도를 내게 된다.

이후 곧바로 PID제어가 이를 끌어내리면서 오버슛이 발생하는데 이는 원래 모터가 가지고 있던 속성이라 어쩔 수 없다는 게 이유일 듯했다.

 

그래서 생각한 해결책은 모터의 원래 속도의 증가, 감속을 완화하는 것이다.

그렇게 하면 모터에 목푯값을 부여했을 때 원래의 속도보다 덜 빠르게 증가하고 감속하기 때문에 오버슛이 줄지 않을까 예상했다.

 

먼저 모터제어값에 0.001배 완화를 적용하지 않았을 때의 그래프이다.

모터 증감부분에 완화가 적용되지 않았을 때

모터제어값에 0.001배 완화를 적용한 결과이다.

모터 증감부분에 완화가 적용 되었을 때

증감 완화를 적용하였더니 기존에 존재했던 오버슛이 많이 줄었다.

효과가 있는듯하다.

 

이처럼 파라미터를 계속해서 변경해 가며 제어 시스템을 구축하니 시간은 조금 걸리지만 쉽게 구현이 가능하였다.

 

아래 코드를 첨부한다.

전체적으로 다듬지는 않은 코드라서 PID를 구현한 함수를 보면 될 듯하다.

주석을 달아놓았으며 코드는 그리 어렵지 않다.

void getRPM(){
  rpm_motor_1 = (float)(motor_1_pulse_count * 6000 / ENC_COUNT_REV); //엔코더로부터 실제 속도를 얻는다.
  rpm_motor_2 = (float)(motor_2_pulse_count * 6000 / ENC_COUNT_REV);//엔코더로부터 실제 속도를 얻는다.
  error_1 = (targetRPM_1 - rpm_motor_1); //목표값과 실제 속도의 차로 에러를 얻는다.
  error_2 = (targetRPM_2 - rpm_motor_2); //목표값과 실제 속도의 차로 에러를 얻는다.
  I_control_1 += error_1*1; //에러를 시간과 적분하여 누적시킨다.
  I_control_2 += error_2*1; //에러를 시간과 적분하여 누적시킨다.
  if (error_1 - error_pre_1 == 0){
    D_control_1 = 0;
  }else{
    D_control_1 = (error_1 - error_pre_1); //에러의 변화율을 계산한다.
  }
  if (error_1 - error_pre_1 == 0){
    D_control_2 = 0;
  }else{
    D_control_2 = (error_2 - error_pre_2); //에러의 변화율을 계산한다.
  }
  PID_1 = error_1*Kp+ I_control_1*Ki + D_control_1*Kd; //각 제어를 더하여 PID제어값을 생성.
  PID_2 = error_2*Kp+ I_control_2*Ki + D_control_2*Kd; //각 제어를 더하여 PID제어값을 생성.
  error_pre_1 = error_1; //현재 에러값을 저장하여 다음 반복 때 사용.
  error_pre_2 = error_2; //현재 에러값을 저장하여 다음 반복 때 사용.
  control_1 = float(((targetRPM_1*0.001 + PID_1)/maxRPM)*255); //목표값에 PID값을 더하여 모터 제어값 생성.
  control_2 = float(((targetRPM_2*0.001 + PID_2)/maxRPM)*255); //targetRPM에 0.01배하여 모터의 증가속도를 스무스하게 적용.
  if (targetRPM_1 == 0){ // 일정 RPM 이하일 때 모터는 돌지 못하는데 PID제어값이 0이 되지 않고 잔류하는 현상이 발생한다.
    control_1 = 0;		//따라서 목표값이 0일경우 PID제어값을 모두 0으로 만들어주어 모터를 완전 정지상태로 한다.
    I_control_1 = 0;
    D_control_1 = 0;
  }
  if(targetRPM_2 == 0){
    control_2 = 0;
    I_control_2 = 0;
    D_control_2 = 0;
  }
  doMotor(MOT_IN_1, MOT_IN_2, MOT_PWM_PIN_A,(control_1>=0)?HIGH:LOW, min(abs(control_1), 255)); // 모터를 작동하는 함수
  doMotor(MOT_IN_3, MOT_IN_4, MOT_PWM_PIN_B,(control_2>=0)?HIGH:LOW, min(abs(control_2), 255)); // 모터를 작동하는 함수
  motor_1_pulse_count = 0; // 1초마다 업데이트하는 엔코더 펄스값을 0으로 초기화
  motor_2_pulse_count = 0; // 1초마다 업데이트하는 엔코더 펄스값을 0으로 초기화
}