AEGLOSLAB 제어공학 종합 핸드북

제7부 실전: ROS2 Humble 프로젝트

7.2 실전 ① 속도·위치 PID

제7부 실전: ROS2 Humble 프로젝트 · 첫 프로젝트. 2.5·3.3의 PID를 ROS2 노드로 구현해 DC모터 속도를 폐루프 제어한다. 토픽으로 연결된 플랜트 노드 + 제어 노드를 실제로 실행해 목표 수렴·정상상태 오차 0·안티와인드업까지 검증한다. turtlesim 위치 제어로 확장.

✅ 검증됨: 이 장의 ROS2 폐루프 노드는 ros:humble 컨테이너에서 실제 실행해 검증했다(verify/ce31_pid_node.py). 안티와인드업 비교(7.2.4)는 u_max=4 시나리오를 재현하는 별도 스크립트(verify/ce31_antiwindup_check.py)로, turtlesim 위치제어(7.2.6)는 노드·로직 검증(verify/ce31_turtle_pid.py)으로 확인했다. 아래 출력은 모두 실측값이다. turtlesim GUI 화면만 독자 환경 몫.

이 장을 마치면 ROS2 토픽으로 폐루프 제어 노드를 구성하고, 정상상태 오차가 적분 작용으로 0이 됨을, 포화 시 안티와인드업이 오버슈트를 줄임을 실행으로 확인해야 한다.

7.2.1 무엇을 만드나 — 토픽으로 닫는 루프

0.1.4의 블록선도를 두 노드로 나눈다:

/motor_speed (측정 ω) /motor_cmd (명령 v) PlantNode DC모터 1차 τω̇+ω=Kv ControllerNode 3.3 PID (목표 r=3.0)
  • PlantNode: DC모터 1차 모델(1.1.7.1) $\tau\dot{\omega}+\omega=K_v$를 적분해 속도 $\omega$를 /motor_speed로 발행, 명령 /motor_cmd를 구독. (실물에서는 이 자리에 모터 드라이버·ros2_control 하드웨어.)
  • ControllerNode: /motor_speed를 구독해 3.3.5 PID로 명령을 계산, /motor_cmd로 발행.

즉 3.3의 제어 루프가 토픽 통신으로 폐루프를 이룬다(7.1.7).

7.2.2 제어 노드 (3.3 PID + rclpy)

3.3.5의 PID 클래스를 그대로 쓰고, rclpy 노드에 통합한다. 참조를 따라가지 않아도 되도록 PID 클래스 본문을 그대로 옮긴다(3.3.5와 동일 — 측정미분+필터·백계산 안티와인드업·설정점가중을 갖춘 완성형):

class PID:
    """디지털 PID: 측정 미분 + 필터 + 백계산 안티와인드업 + 설정점 가중 (3.3.5)."""
    def __init__(self, Kp, Ki, Kd, dt, u_min, u_max, N=10, b=1.0):
        self.Kp, self.Ki, self.Kd, self.dt = Kp, Ki, Kd, dt
        self.u_min, self.u_max = u_min, u_max
        self.Tf = 1.0 / N                                    # 미분 필터 시정수 (3.3.5: Tf=1/N)
        self.Tt = (Kp / Ki) if Ki else dt                    # 백계산 추종 시정수≈Ti
        self.b = b                                           # 설정점 가중(P)
        self.I = 0.0          # 적분기 상태
        self.D = 0.0          # 필터된 미분 상태
        self.y_prev = None

    def update(self, r, y):
        dt = self.dt
        if self.y_prev is None:
            self.y_prev = y
        e = r - y
        P = self.Kp * (self.b * r - y)                       # 설정점 가중 P
        if self.Tf > 0.0:
            self.D = (self.Tf * self.D - self.Kd * (y - self.y_prev)) / (self.Tf + dt)
        else:
            self.D = 0.0
        u_unsat = P + self.I + self.D
        u = max(self.u_min, min(self.u_max, u_unsat))        # 포화
        self.I += self.Ki * e * dt + (u - u_unsat) * (dt / self.Tt)   # 백계산 안티와인드업
        self.y_prev = y
        return u

이제 rclpy 노드에 통합한다:

import rclpy
from rclpy.node import Node
from std_msgs.msg import Float64

DT = 0.02      # 50 Hz

class ControllerNode(Node):
    def __init__(self, setpoint=3.0):
        super().__init__('controller')
        self.r = setpoint
        self.pid = PID(Kp=2.0, Ki=4.0, Kd=0.0, dt=DT, u_min=-10.0, u_max=10.0)  # 위 PID
        self.pub = self.create_publisher(Float64, 'motor_cmd', 10)
        self.create_subscription(Float64, 'motor_speed', self.on_speed, 10)
    def on_speed(self, msg):
        y = msg.data                                   # ① 측정 ω
        u = self.pid.update(self.r, y)                 # ② PID
        m = Float64(); m.data = u; self.pub.publish(m) # ③ 명령 발행

(전체 노드·PlantNode(플랜트 모델)는 verify/ce31_pid_node.py 참조 — 위 코드와 동일하며, 컨테이너에서 실행해 검증했다.) 실제로는 Float64 대신 표준 메시지(geometry_msgs/Twist의 angular.z 등, 7.1.3)를 쓴다.

7.2.3 실행 결과 — 목표 수렴, 정상상태 오차 0 (실측)

플랜트·제어 노드를 한 프로세스에서 MultiThreadedExecutor로 5초 실행한 실제 출력:

t= 0.2s  r=3.00  ω=1.697  u=4.303
t= 0.4s  r=3.00  ω=2.385  u=3.615
t= 0.6s  r=3.00  ω=2.733  u=3.267
t= 0.8s  r=3.00  ω=2.884  u=3.116
t= 1.0s  r=3.00  ω=2.950  u=3.050
t= 1.4s  r=3.00  ω=2.990  u=3.010
t= 2.0s  r=3.00  ω=2.999  u=3.001
t= 2.2s  r=3.00  ω=3.000  u=3.000
   ⋮
FINAL: setpoint=3.0  measured=3.0000  err=0.0000   → CE31_PID_PASS

읽기:

  • 속도 $\omega$가 0 → 목표 3.0으로 매끄럽게 수렴, 약 2초에 정착(PI 튜닝).
  • 정상상태 오차 = 0.0000: PI의 적분(I) 항이 오차를 정확히 제거했다 — 1.3.6(형 1)·2.5.2.2의 예측이 실제 ROS2 루프에서 확인됨.
  • 명령 $u$가 초기 4.3에서 정상상태 3.0(=$r/K$)으로 수렴 — 정상상태에 필요한 입력을 적분기가 스스로 찾았다.

이것이 책 전체의 중간 결산이다: 0.1의 "측정→오차→보정" 직관이, 2.5의 PID 이론이, 3.3의 구현이, 7.1의 ROS2 구조가 한 폐루프에서 실제로 목표를 추종한다.

7.2.4 안티와인드업 — 포화에서의 차이 (실측)

2.5.6.3의 안티와인드업을 과도 구간만 포화하는 시나리오(u_max=4: 정상상태 $u=3$은 도달 가능하나 초기 요구 $u\approx6$은 포화)로 검증한다. verify/ce31_pid_node.py는 이 절의 시나리오와 무관하게 u_max=10(거의 포화 안 됨)·안티와인드업 토글도 없는 별개 스크립트라, 이 비교는 전용 스크립트 verify/ce31_antiwindup_check.py(같은 DC모터 플랜트·PID 게인, u_max=4로 좁히고 백계산 유/무를 토글)로 새로 만들어 확인했다:

anti_windup=False (naive)     오버슈트=  2.67%   피크시각= 1.08s   최종=3.0000
anti_windup=True  (백계산)       오버슈트= -0.00%   피크시각= 8.34s   최종=3.0000
  • 둘 다 적분 작용으로 최종 3.0000에 도달하지만, 백계산 안티와인드업이 오버슈트(2.67%→−0.00%)를 없앤다. 포화로 잘린 양을 적분기가 추종해 누적 폭주를 막은 결과(2.5.6.3). anti_windup=True 쪽은 오버슈트 자체가 없어(단조 수렴) "피크"가 사실상 최종값 근처의 부동소수점 잡음이다.
  • 이 플랜트·튜닝에서는 차이가 작아 보이지만(완만한 포화), 더 느린 플랜트·긴 포화에서는 효과가 극적으로 커진다 — 직접 u_max·$K_i$를 바꿔 확인하라.

정직: 위 수치는 이 모델·튜닝 기준 실측이다(ce-verify 도커, numpy 2.2.6, verify/ce31_antiwindup_check.py 직접 실행). 실물 모터는 마찰·지연·잡음으로 다르게 나오니(1.1.11·3.3.9), 여러분의 시스템에서 재튜닝·재검증하라.

7.2.5 속도에서 위치로 — 적분기가 하나 더

위치 제어는 속도 제어 위에 적분기가 하나 더 붙는다(1.1.7: $\Theta/V = \Omega/V \cdot1/s$). 두 방식:

  • 단일 루프: 위치 오차에 PID 직접. 위치는 1형(적분기 내장)이라 P만으로도 계단 위치 오차 0(1.3.6).
  • 종속(cascade) 루프: 바깥 위치 루프가 속도 목표를 만들고, 안쪽 속도 루프(7.2.3)가 그것을 추종. 산업 모션 제어의 표준 — 안쪽 루프가 빨라 외란·포화를 먼저 처리한다.

종속 구조는 "느린 바깥(위치) + 빠른 안쪽(속도)"으로 대역폭을 분리한다(2.3·2.6의 정신). 안쪽 루프를 충분히 빠르게 튜닝한 뒤 바깥을 설계한다. 심화: 피드포워드까지 포함한 제어 구조 전반은 7.7(제어 구조: 피드포워드·종속) 참고.

7.2.6 turtlesim으로 — 실제 GUI에서 (독자 환경)

turtlesim(0.3.6)은 위치 제어의 훌륭한 첫 실습이다. 거북이의 /turtle1/pose(turtlesim/msg/Pose: 위치·자세)를 구독해 목표 $(gx,gy)$까지 거리·방위 오차를 P 제어로 /turtle1/cmd_vel(geometry_msgs/Twist)에 반영해 보낸다. 7.2.2의 ControllerNode와 같은 골격(측정 콜백 안에서 오차 계산→제어→발행)이다:

import math
import rclpy
from rclpy.node import Node
from turtlesim.msg import Pose
from geometry_msgs.msg import Twist

class TurtleControllerNode(Node):
    """turtlesim 위치제어: /turtle1/pose 구독, 목표(gx,gy)까지 거리·방위 오차를
    비례(P) 제어로 /turtle1/cmd_vel(Twist)로 내보낸다."""
    def __init__(self, goal_x=8.0, goal_y=8.0):
        super().__init__('turtle_pid')
        self.gx, self.gy = goal_x, goal_y
        self.Kp_lin = 1.0          # 거리 → 선속도 비례 이득
        self.Kp_ang = 4.0          # 방위오차 → 각속도 비례 이득
        self.max_lin = 2.0         # turtlesim 화면 한계 고려 속도 제한
        self.tol = 0.05            # 도달 판정 허용오차 [m]
        self.pub = self.create_publisher(Twist, '/turtle1/cmd_vel', 10)
        self.create_subscription(Pose, '/turtle1/pose', self.on_pose, 10)

    def on_pose(self, msg: Pose):
        ex = self.gx - msg.x
        ey = self.gy - msg.y
        dist = math.hypot(ex, ey)                       # ① 측정 → 거리오차
        heading_err = math.atan2(ey, ex) - msg.theta     # 목표 방위 − 현재 자세
        heading_err = math.atan2(math.sin(heading_err), math.cos(heading_err))  # [-π,π] 정규화

        cmd = Twist()
        if dist < self.tol:
            self.pub.publish(cmd)                        # ② 도달 → 정지 명령
            self.get_logger().info(f'도달: dist={dist:.3f}')
            return
        cmd.linear.x = min(self.Kp_lin * dist, self.max_lin)   # ② P 제어(선속도)
        cmd.angular.z = self.Kp_ang * heading_err               # ② P 제어(각속도)
        self.pub.publish(cmd)                            # ③ 구동(명령 발행)

def main():
    rclpy.init(); rclpy.spin(TurtleControllerNode(goal_x=8.0, goal_y=8.0)); rclpy.shutdown()

방위오차를 각속도로, 거리를 선속도로 P 제어하는 단일 루프 위치 제어다(7.2.5) — 거리가 멀면 빨리, 목표를 등지고 있으면 먼저 실행된다(각속도 이득 Kp_ang이 더 크게 설정되어 방위부터 맞춘다).

# 터미널 1: 거북이 시뮬레이션 (GUI 필요)
ros2 run turtlesim turtlesim_node
# 터미널 2: 내 제어 노드
ros2 run ce_examples turtle_pid

노드 생성·구독/발행·제어 로직은 검증했다(ros:humble-ros-base + ros-humble-turtlesim, turtlesim.msg·geometry_msgs.msg import 및 TurtleControllerNode 생성 확인). on_pose 콜백을 가짜 Pose로 직접 호출해 로직도 확인:

pose=(5.5, 5.5, θ=0.00) -> cmd_vel: linear.x=2.0000  angular.z=3.1416   (목표(8,8)까지 대각선, 최대속도+선회)
pose=(0.0, 0.0, θ=1.57) -> cmd_vel: linear.x=2.0000  angular.z=-3.1384  (등진 방향 → 큰 각속도로 방위부터 교정)
pose=(7.98, 7.98, θ=0.00) -> cmd_vel: linear.x=0.0000 angular.z=0.0000  (dist=0.028 < tol → 정지)

⚠️ turtlesim_node는 Qt GUI라 headless 컨테이너에서는 화면이 표시되지 않는다(이 책의 자동 검증 밖, 7.1.8과 같은 한계). 위 확인은 노드 기동·통신 설정·제어 로직까지고, 거북이가 실제로 목표까지 매끄럽게 수렴하는 폐루프 거동은 검증 필요 — 데스크톱 ROS2 환경(0.3)에서 직접 실행해 확인하라. 게인(Kp_lin, Kp_ang)은 예시값이라 진동·오버슈트가 보이면 2.5.4처럼 낮춰 튜닝하라.

7.2.7 정직 — 시뮬레이션에서 실물로

  • 시뮬레이션 플랜트 = 모델: PlantNode는 모델(1차 DC모터)이다. 실물은 마찰·포화·지연·잡음·바이어스로 다르다(1.1.11) → 실물 튜닝이 필요.
  • 타이밍: 컨테이너 검증은 단일 프로세스·완만한 부하다. 분산·실물에서는 지터·지연이 더해진다(3.3.9·7.1.4) — 빠른 루프는 ros2_control로.
  • 메시지 타입: 예제는 Float64로 단순화했다. 실전은 표준 메시지(Twist·JointState)와 QoS를 맞춰야(7.1.3·7.1.8).

7.2.8 정리 — 그리고 다음 장

  • ROS2 토픽으로 플랜트 노드 + 제어 노드를 폐루프로 연결, 3.3 PID로 속도를 제어했다(7.2.1·7.2.2).
  • 실측 검증: 속도가 목표 3.0에 수렴, 정상상태 오차 0(PI 적분 작용), 안티와인드업이 포화 오버슈트를 2.67%→−0.00%로 줄임 — 1.3·2.5의 이론이 실제 ROS2 루프에서 확인됨(7.2.3·7.2.4).
  • 위치 제어는 적분기가 하나 더(단일/종속 루프), turtlesim으로 실습(7.2.5·7.2.6). 시뮬레이션→실물 격차는 정직하게(7.2.7).

다음 7.3에서는 이동로봇의 경로 추종(차동구동, Pure Pursuit + PID, Gazebo)으로 — 1차원 속도에서 2차원 주행으로 확장한다.

7.2.9 연습·생각해 볼 것

  1. 정상상태 오차. 7.2.3에서 $K_i=0$(P만)으로 두면 속도가 목표 3.0에 도달하나? 1.3.6으로 예측하고, verify/ce31_pid_node.py에서 $K_i=0$으로 바꿔 확인하라.
  2. 튜닝. $K_p$를 키우면 수렴이 빨라지나 무엇이 위험한가? $K_d$를 더하면? 직접 바꿔 응답을 관찰하라. (2.5.4)
  3. 안티와인드업. 7.2.4에서 u_max를 더 좁히거나(예 3.5) $K_i$를 키우면 안티와인드업 유무 차이가 어떻게 커지나? 왜? (2.5.6.3)
  4. 종속 루프. 위치 제어를 단일 루프 대신 종속(위치→속도→PID)으로 작성하는 이점은? 안쪽 루프는 바깥보다 얼마나 빨라야 하나? (7.2.5)
  5. 메시지. 이 예제의 Float64를 실제 이동로봇용 geometry_msgs/Twist로 바꾸면, 속도 명령은 어느 필드에 담나? (7.1.3)
  6. (손으로) verify/ce31_pid_node.py를 실행해(컨테이너 또는 0.3 환경) 수렴을 재현하고, 목표 setpoint·$K_p,K_i,K_d$·u_max를 바꿔 가며 응답을 관찰하라. 가능하면 turtlesim으로 위치 제어까지. (검증된 스크립트)

막히면 "측정 토픽 구독 + PID + 명령 토픽 발행, 적분이 정상상태 오차를 제거하고, 안티와인드업이 포화를 막는다 — 실제로 확인됨"으로 돌아가라.