파이썬 예제 (PID 제어기)

들어가며

이번 시간에는 PID 제어기를 Python, PyQt5, Matplotlib를 이용해 만들어 보았습니다.

결과물을 먼저 살펴보면 아래와 같습니다.

[완성 결과물]


개발환경

코드가 작성된 개발환경은 아래와 같습니다.

  • Python 3.8.8 (64bit), Pycharm 2020.3.4

  • PyQt5 5.15.3, Matplotlib 3.3.4

 

개요

제어기 (Controller) 는 어떤 시스템의 응답이 사용자가 내린 명령에 근접하도록 출력을 조절해 주는 장치를 의미합니다.

예를 들면 자동차의 크루즈(항속) 제어는 운전자가 직접 가속 페달을 조절하지 않고, 속도를 설정하면 현재속도와 설정속도를 비교해 제어기가 연료 분사량을 조절해 설정속도에 도달, 유지되도록 합니다.

그런데 현재 차량이 정지되어 있는 상태라고 가정한다면, 크루즈 제어기는 가능한 빨리 설정속도에 도달하기 위해 최대 연료 분사량을 셋팅하게 되고 운전자는 급가속이 발생해 위험한 상황을 맞이하게 됩니다.

반대의 경우, 너무 천천히 가속된다면 이 또한 불편한 상황일 것입니다.

이런 상황을 수학적 모델로 다루는 분야를 제어 시스템 공학이라고 하며, 그 중 가장 많이 사용되는 대표적인 제어기가 PID 제어기 입니다.

PID 제어기는 기본적으로 피드백 (Feedback) 제어기의 형태이며, 현재 출력값을 설정 (목표)치와 비교해 오차를 계산, 오차 신호의 비례항, 적분항, 미분항을 구해 이를 합하는 방식으로 구성됩니다.

PID 키워드의 의미는 다음과 같습니다.

  •  P : Proportional (비례)

  •  I : Integral (적분)

  •  D : Differential (미분)

아래는 위키 백과에서 참조한 PID 제어기의 수학적 모델과 구조입니다.

[PID 제어기 수학모델, 출처 : 위키백과]


[PID 제어기 구조, 출처 : 위키백과]

 

이 글에서 PID 제어기에 대한 수학적 개념을 설명하지 않습니다. 인터넷에 좋은 자료가 많으므로 참조해 보시기 바랍니다.

주로 PID 제어기의 소프트웨어적 구현에 대한 내용에 초점을 맞추어 설명하겠습니다. 

 

PID 제어기 튜닝

이 프로그램을 완성한 후, 목표 시스템에 맞는 적절한 비례, 적분, 미분 계수를 찾는 과정은 여러분이 직접 튜닝 과정을 거쳐야 합니다.

일반적으로 PID제어기를 만들고, 실제 시스템의 장비 출력치를 아날로그 (전압, 전류 등), 또는 디지털 (A/D변환 후 통신)로 피드백하도록 구성한 후 시운전 과정 (Commissioning)을 거쳐 시스템에 적용하게 됩니다.

제작된 프로그램의 주요 기능은 아래와 같습니다.

  •  실시간 차트 (Matplotlib FuncAnimation Class 이용)

  •  PyQt5를 이용한 GUI 구성

  •  갱신시간(t), 비례, 미분, 적분 계수 등 변경

 

소스코드

전체 소스코드는 2개의 파일 (main.py, pid_controller.py) 로 구성되어 있습니다.

메인 함수는 main.py 파일에 구성되어 있으며, 두 파일을 같은 경로에 두고 실행하면 됩니다.

main.py 소스코드

기본창 (위젯) 에 각종 컨트롤, 차트를 구성하는 부분을 담당합니다.

from PyQt5.QtWidgets import QApplication, QWidget, QGroupBox, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit, QPushButton
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QDoubleValidator
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from pid_controller import PID_Control
import sys
from collections import deque

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class Form(QWidget):

    def __init__(self):
        super().__init__()
        self.setWindowTitle('Ocean Coding School')
        self.initUi()

    def initUi(self):
        vbox = QVBoxLayout()
        self.setLayout(vbox)

        gb1 = QGroupBox('Realtime Chart')
        gb2 = QGroupBox('Set Coefficient')
        gb3 = QGroupBox('Description')

        vbox.addWidget(gb1)

        hbox = QHBoxLayout()
        hbox.addWidget(gb2)
        hbox.addWidget(gb3)
        hbox.setStretchFactor(gb2, 5)
        hbox.setStretchFactor(gb3, 5)
        vbox.addLayout(hbox)

        # GroupBox1
        vbox = QVBoxLayout()
        gb1.setLayout(vbox)

        self.fig = plt.Figure()
        self.canvas = FigureCanvasQTAgg(self.fig)
        vbox.addWidget(self.canvas)

        # GroupBox2
        gbox = QGridLayout()
        gb2.setLayout(gbox)

        _txt = ('갱신시간(sec)', '최소', '최대', '목표치', '비례이득(kp):', '적분이득(ki):', '미분이득(kd):')
        self.def_Coef = (0.1, -200., 200., 50., 0.1, 0.5, 0.01)
        self.coef = []
        self.lineEdits = []
        self.slds = []

        for i in range(len(_txt)):
            Lb = QLabel(_txt[i])
            Le = QLineEdit(str(self.def_Coef[i]))
            Le.setValidator(QDoubleValidator(-100, 100, 2))
            gbox.addWidget(Lb, i, 0)
            gbox.addWidget(Le, i, 1)
            self.lineEdits.append(Le)

        self.btn = QPushButton('Start')
        self.btn.setCheckable(True)
        self.btnReset = QPushButton('Reset')
        gbox.addWidget(self.btn, len(_txt)+1, 0)
        gbox.addWidget(self.btnReset, len(_txt)+1, 1)

        # GroupBox3
        vbox = QVBoxLayout()
        gb3.setLayout(vbox)
        self.desc = QLabel()
        vbox.addWidget(self.desc)

        # signal
        self.btn.clicked.connect(self.onClickStart)
        self.btnReset.clicked.connect(self.onClickReset)

    def onClickStart(self):
        if self.btn.isChecked():
            self.btn.setText('Stop')
            self.enableCoefficient(False)
            if hasattr(self, 'ani'):
                self.resetAll()
                self.ani.event_source.start()
            else:
                self.startChart()
        else:
            self.btn.setText('Start')
            self.enableCoefficient(True)
            self.ani.event_source.stop()

    def onClickReset(self):
        if hasattr(self, 'ani'):
            self.stopChart()
            self.fig.clear()
            self.canvas.draw()
            del(self.ani)
        self.btn.setChecked(False)
        self.btn.setText('Start')
        self.enableCoefficient(True)

    def enableCoefficient(self, flag):
        for le in self.lineEdits:
            le.setEnabled(flag)

    def resetCoefficient(self, isDefault=False):
        self.coef.clear()
        # index = 0:갱신시간(t, sec), 1:최소(min), 2:최대(max) 3:목표치(sp), 4:비례이득(kp), 5:적분이득(ki), 6:미분이득(kd)
        for i in range(len(self.def_Coef)):
            if isDefault:
                v = self.def_Coef[i]
                self.lineEdits[i].setText(str(v))
            else:
                v = float( self.lineEdits[i].text() )
            self.coef.append(v)
        return self.coef

    def resetAll(self):
        # pid
        _dt, _min, _max, _sv, _kp, _ki, _kd = self.resetCoefficient(False)
        self.pid.update(_dt, _min, _max, _kp, _ki, _kd)
        # interval (ms)
        self.ani.event_source.interval = _dt*1000
        # scale
        self.ax.set_ylim(_min, _max)

    def startChart(self):
        self.resetCoefficient(False)
        _dt = self.coef[0] * 1000
        self.ani = animation.FuncAnimation(fig=self.fig, func=self.drawChart, init_func=self.initPlot, blit=False, interval=_dt)
        self.canvas.draw()

    def stopChart(self):
        self.ani._stop()

    def initPlot(self):
        _dt, _min, _max, _sv, _kp, _ki, _kd = self.resetCoefficient(True)

        self.pid = PID_Control(_dt, _min, _max, _kp, _ki, _kd)
        self.pv = _min
        self.inc = 0

        self.x = deque([], 100)
        self.y = deque([], 100)
        self.hy = deque([], 100)

        self.ax = self.fig.subplots()
        self.ax.set_title('PID Control')
        self.ax.set_ylim(_min, _max)
        self.line, = self.ax.plot(self.x, self.y, label='output')
        self.spline, = self.ax.plot(self.x, self.hy, linestyle='--', label='setpoint', color='r', alpha=0.7)
        self.ax.legend()
        return self.line, self.spline

    def drawChart(self, i):
        inc, desc = self.pid.calc(self.coef[3], self.pv)
        self.desc.setText(desc)
        self.pv += inc
        self.y.append(self.pv)
        self.x.append(i)
        self.hy.append(self.coef[3])

        self.line.set_data(self.x, self.y)
        self.spline.set_data(self.x, self.hy)
        self.ax.relim()
        self.ax.autoscale_view(True, True, False)
        return self.line, self.spline

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = Form()
    w.show()
    sys.exit(app.exec_())

[라인 1~9]

필요한 패키지, 모듈을 불러오는 구문.

from module import * (*은 all의 의미) 의 형태로 모듈의 모든 요소를 불러올 수도 있지만 불 필요한 요소를 불러오면 메모리만 낭비되므로 가능하면 필요한 요소 (클래스, 함수 등)의 이름을 직접 입력.


[라인 14]

4K 모니터를 위한 해상도 맞춤 설정.

 

[라인 13~18]

위젯 클래스와 그 생성자 함수. (객체 생성시 최초 1회만 호출)

위젯에 컨트롤을 배치하는 initUI() 함수 호출

예) 위젯(QWidget) : Qt의 클래스를 의미하며, 일종의 윈도우 창.

(C++ MFC 를 공부한 경험이 있다면, CWnd Class와 유사한 개념)


[라인 20~77]

위젯의 레이아웃을 잡고, 컨트롤 생성 및 배치를 담당하는 함수.

Qt Designer를 이용 편리하게 컨트룰을 생성, 배치할수 있지만, 코드에서 직접 구현 선호.

박스 레이아웃을 이용해 전체 GUI를 3가지 파트로 구성하고, 각 파트별 컨트롤 생성, 추가.

[위젯 레이아웃 구성]

 

[라인 79~91]

시작버튼 클릭시 호출되는 슬롯함수.

시작버튼은 Toggle 속성을 부여해 Checked, Unchecked 상태 확인 필요. (버튼 2개 불필요)

처음 시작버튼이 눌러진 경우 startChart() 함수호출, Stop 상태에서 이어서 진행되는 경우 Matplotlib Event Source의 start() 함수 호출.


[라인 93~101]

리셋버튼 클릭시 호출되는 슬롯함수.

실시간 차트를 정지시키고, 삭제.

 

[라인 103~105]

제어기에 필요한 계수 (Coefficient) 입력치 컨트롤 활성화, 비활성화를 담당.

제어기 동작시 비활성화, 멈추면 활성화.

[입력 계수 활성, 비활성]

[라인 107~117]

QLineEdit 컨트롤 입력치를 모아서 저장하는 변수, self.coef = [] 리스트의 갱신처리 담당.

예) self.coef =  [갱신시간, 최소, 최대, 목표치, 비례, 적분, 미분 계수 ] 

전달인자 isDefault 가 참 (True)인 경우, 기본값으로 계수(t, kp, kd, ki 등) 초기화.

전달인자 isDefault 가 거짓 (False)인 경우, 사용자가 작성한 값으로 변경.


[라인 119~126]

PID 제어기가 가동 정지되고, 설정 계수가 변경된 경우 호출되는 함수.

이후 설명할 PID_Control Class의 객체인 self.pid를 변경된 계수로 업데이트.

더불어 실시간 차트의 갱신시간(t) 과 Y축 최대, 최소치 업데이트. 


[라인 128~132]

시작 버튼 클릭시 실시간 차트를 생성하는 함수.

MatplotlibFuncAnimation Class 객체를 생성하고, 생성자 전달인자로 매번 차트를 그릴때마다 호출될 Callback Function (drawChart 함수)과 Initialize Function (initPlot 함수) 를 설정.

실제 차트는 Matplotlib FigureCanvasQTAgg Class 객체인 self.canvas 에 그려짐.

Matplot의 차트는 PyQt5의 위젯에 Embedding 가능하도록 클래스를 제공하는데, 그 클래스가 FigureCanvasQTAgg 임. 

 

[라인 134~135]

실시간 차트 정지 함수.

 

[라인 137~154]

실시간 차트(FuncAnimation) 초기화 함수.

실시간 차트를 정해진 간격 (Interval) 으로 그리기 전, 최초 1회만 수행되어 차트의 초기화 담당.

뒤에 설명할 PID_Control Class 객체 생성, 초기화 진행.

(오차신호의 비례, 미분, 적분 계산수행)

차트에 사용할 X, Y축 데이터 (100개)를 Deque (Double Ended Queue)로 생성.

(실시간 챠트의 데이터가 추가되면 맨 앞의 데이터가 빠져나와 삭제되는 방식)

X축은 시간(interval, t), Y축은 현재 설정치(Pv) 값을 표시.

서브 플롯 (self.ax)을 생성하고, 2개의 플롯 라인 생성.

(self.line:현재값, self.spline:설정치)

 

[라인 156~168]

실시간 차트의 콜백 함수.

FuncAnimation Class의 생성시 전달한 Interval 간격마다 호출되는 함수. (milli second 기준, 즉 1000이면 1초 간격 호출)

목표값(Sv)현재값(Pv)을 PID_Control 객체의 calc() 함수 전달인자로 넘기고, 오차신호의 비례, 미분, 적분값의 합을 리턴받아 현재 값에 반영. 

갱신된 현재값은 Y축 Deque 객체의 맨뒤에 추가되며, 맨 앞의 가장 오래된 데이터는 덱에서 자동 삭제.

 

[라인 170~174]

파이썬의 메인함수.

앱을 생성하고, Form 위젯과 연결한 후 실행.

이때, 앞서 설명한 모든 코드들이 Form Class (QWidget에서 상속)의 생성자와 함께 동작.

 

pid_controller.py 소스코드

앞서 설명한 main.py는 GUI를 구성하는 코드가 대부분이며, 실제 PID 제어기는 이 파일에 선언된 PID_Control Class에 의해 구현됩니다.

# kp:비례이득, ki:적분이득, kd:미분이득

class PID_Control:

    def __init__(self, _dt, _min, _max, _kp, _ki, _kd):
        self.update(_dt, _min, _max, _kp, _ki, _kd)

        self.pre_error = 0
        self.integral = 0

    def update(self, _dt, _min, _max, _kp, _ki, _kd):
        self.dt = _dt
        self.min = _min
        self.max = _max
        self.kp = _kp
        self.ki = _ki
        self.kd = _kd

    def calc(self, sv, pv):
        error = sv - pv
        # 비례
        kp = self.kp * error

        # 적분
        self.integral += error * self.dt
        ki = self.ki * self.integral

        # 미분
        kd = (error - self.pre_error) / self.dt
        kd = self.kd * kd

        # 합산
        result = kp + ki + kd

        if result > self.max:
            result = self.max
        elif result < self.min:
            result = self.min

        self.pre_error = error

        #description
        desc = f'Kp :\t{kp:.3f}\nKi :\t{ki:.3f}\nKd :\t{kd:.3f}\nPv :\t{pv:.3f}\nSv :\t{sv:.3f}'

        return result, desc


[라인 3~9]

PID_Control Class의 생성자 함수.

PID 제어 연산에 필요한 변수(시간, 최대, 최소, 비례, 미분, 적분 이득 등)를 설정하고 초기화.

 

[라인 11~17]

PID 제어기의 설정치가 변경되는 경우, 변경된 설정치 저장용 함수.

 

[라인 19~45]

PID 제어기 시간 간격마다 호출되며, 현재값, 설정(목표)치를 입력받아 계산 수행.

이 함수는 앞서 설명한 main.py의 drawChart() 콜백 함수에 의해 호출.

오차 비례, 적분, 미분 계산 및 합산.

가중합이 최대치보다 큰경우, 또는 최소치보다 작은 경우 처리.

현재 가중치를 숫자로 보기위해 desc 문자열 변수에 저장.

가중합과 수치문자열을 리턴.

 

이상으로 모든 설명을 마칩니다.

Matplotlib의 실시간 차트가 잘 이해되지 않는다면 이 예제 (링크)를 먼저 살펴보면 도움이 됩니다.


감사합니다.

댓글

  1. 안녕하세요 질문이 있는게 혹시 가능할까요?

    답글삭제
  2. 안녕하세요, PID CONTROL 되어서 Control TREND가 보이는 값(초당 data)을 가지고,
    kp, ki, kd는 알고 있음.
    다른 kp, ki, kd를 적용하였을때의 trend 를 알 수 있나요?(역으로 최적의 PID값을 확인)
    실제 trend가 몇가지(pid값이 다른) TREND 가 있어야 할라나요..?
    파이썬으로, 가능한 수준인가 궁금합니다..

    답글삭제
    답글
    1. 안녕하세요.

      아시다시피 대부분의 제어 시스템은 1을 입력하면 1이, 2를 입력하면 2가 나오는 선형구조가 아닙니다.

      비선형형태의 입출력을 갖는 제어시스템을 하나의 트렌트로 리버스엔지니어링 하기는 불가능하며 여러가지 트렌드가 있다면 최적의 해를 찾을 수 있지 않을까요.

      구현은 수학적으로 가능하면 파이썬 or 프로그래밍 언어로도 가능합니다.

      감사합니다.

      삭제
  3. 혹시 제어대상이 무엇인가요? 제어 대상이 어떤것인지 궁금합니다.

    답글삭제
    답글
    1. 제어 대상은 너무 다양해서...

      예를 들면 예제에서 언급한 자동차의 ECU가 제어하는 연료분사량 제어장치, 또는 선박의 Thruster, 드론의 프로펠러 모터, 각종 컨트롤 밸브 등 단순 ON/OFF 제어가 아닌 대부분의 시스템이 PID 제어의 대상이 됩니다.

      참고로 보일러, 에어컨 등에도 사용됩니다.

      삭제
  4. Traceback (most recent call last):
    File "c:\Users\(이름)\OneDrive\바탕 화면\파일\(해당 패키지 폴더명)\main.py", line 7, in
    from pid_controller import PID_Control
    ImportError: cannot import name 'PID_Control' from 'pid_controller' (c:\Users\(이름)\OneDrive\바탕 화면\파일\(해당 패키지 폴더명)\pid_controller.py)
    PS C:\Users\(이름)\OneDrive\바탕 화면\파일\해당 패키지 폴더명> 라고 오류가 뜨는데 어떻게 해결해야되나요?

    답글삭제
    답글
    1. 전체 소스코드는 2개의 파일 (main.py, pid_controller.py) 로 구성되어 있습니다.
      메인 함수는 main.py 파일에 구성되어 있으며, 두 파일을 같은 경로에 두고 실행하면 됩니다.

      삭제
  5. 좋은 정보 감사합니다

    답글삭제

댓글 쓰기

이 블로그의 인기 게시물

Qt Designer 설치하기

C++ 예제 (소켓 서버, 이미지, 파일전송)