Devin.KR

로봇 · 기본

좌표·센서·구동기의 기초

로봇 시스템의 구성 - 감지·판단·구동

센서·제어기·구동기, 개루프·폐루프, 소프트웨어 구성도

개발자KR · 원고 갱신

이 장에서 배우는 것

이 책은 두 바퀴로 움직이는 차동 구동(differential drive) 로봇 하나를 예제로 삼아 좌표, 센서, 구동기를 차례로 다룬다. 벡터나 회전행렬 같은 수학으로 들어가기 전에, 이 장에서는 로봇 소프트웨어가 대체로 어떤 모양을 하고 있는지부터 정리한다. 센서·제어기·구동기가 각각 무슨 일을 하는지, 그리고 그 셋이 고리처럼 연결되어야 하는 이유를 작은 실행 예제로 확인한다.

  • 로봇 소프트웨어를 감지·판단·구동 세 단계로 나누어 설명할 수 있다
  • 개루프 제어와 폐루프 제어를 코드 수준에서 구분할 수 있다
  • 폐루프 제어가 예상 밖 상황(부하 변화 등)에서 왜 유리한지 실행 결과로 확인할 수 있다
  • 이후 장에서 만들 센서·구동기 코드가 이 구조 어디에 들어가는지 가늠할 수 있다

문제 상황

차동 구동 로봇 두 대를 다루는 상황을 가정한다. 목표 속도를 정하고, 그 속도에 해당하는 PWM 값을 계산해 왼쪽·오른쪽 모터에 한 번 내보내는 코드를 작성했다. 평평한 타일 바닥에서 시험했을 때는 문제가 없었다. 그런데 같은 코드를 카펫이 깔린 구간에 놓았더니 로봇이 목표보다 느리게 움직였고, 두 바퀴가 받는 저항이 서로 달라 진행 방향까지 살짝 틀어졌다.

원인을 코드에서 찾아보면, 프로그램은 처음에 계산한 PWM 값을 그대로 계속 내보낼 뿐 로봇이 실제로 얼마나 빠르게 움직이고 있는지는 한 번도 확인하지 않는다. 바닥 저항이 달라지면 같은 PWM이라도 실제 속도가 달라지는데, 이 코드는 그 차이를 알아챌 방법이 없다. 명령을 미리 정해두고 그대로 밀어붙이는 방식과, 실제 결과를 확인하면서 명령을 고쳐나가는 방식은 근본적으로 다르다. 이 차이가 이 장의 핵심이다.

센서·제어기·구동기

로봇 소프트웨어는 대체로 세 가지 역할로 나뉜다.

  • 센서(sensor) — 로봇이나 주변 환경의 상태를 숫자로 바꾼다. 바퀴가 얼마나 돌았는지 세는 엔코더, 기울기와 각속도를 재는 IMU, 앞에 있는 물체까지의 거리를 재는 거리 센서 등이 있다. 이 장 이후 센서를 다루는 장에서 각각을 자세히 살펴본다.
  • 제어기(controller) — 목표와 센서 측정값을 비교해 다음에 무엇을 할지 정한다. "판단"에 해당하는 부분으로, 단순히 오차를 계산하는 것부터 시작한다.
  • 구동기(actuator) — 제어기가 정한 명령을 실제 움직임으로 바꾼다. DC 모터와 PWM, 서보 모터가 대표적이며, 구동기를 다루는 장에서 다시 다룬다.

이 셋이 한 방향으로만 흐르면(구동기까지만 가고 끝나면) 개루프이고, 구동기가 만든 결과를 센서가 다시 측정해서 제어기에 돌려주면 폐루프가 된다. 아래 그림은 이 순환 구조를 보여준다. 빨간 화살표가 바로 "결과를 다시 측정한다"는 뜻이며, 이 화살표가 없는 시스템이 개루프다.

센서-제어기-구동기가 순환하며 재측정 화살표가 있어야 폐루프가 된다

개루프와 폐루프

개루프(open loop) 제어는 센서를 쓰지 않는다. "이 정도 명령을 내리면 이만큼 움직일 것이다"라는 모델을 미리 세워두고, 그 모델대로만 명령을 낸다. 정해진 시간만큼 전진하거나, 정해진 각도만큼 회전하는 코드가 대표적이다. 구현이 단순하고 타이밍이 예측 가능하다는 장점이 있지만, 모델이 틀리거나 바닥 마찰·배터리 전압처럼 외부 요인이 바뀌면 오차가 쌓여도 스스로 알아채지 못한다.

폐루프(closed loop) 제어는 매 제어 주기마다 센서로 실제 상태를 측정하고, 목표와의 차이(오차)만큼 명령을 다시 낸다. 예상하지 못한 외란에도 스스로 보정한다는 장점이 있지만, 센서가 있어야 하고 센서 잡음과 지연을 감안해야 하며(잡음을 줄이는 방법은 뒤에서 다룬다), 오차를 명령에 반영하는 비율(이득)을 잘못 잡으면 오히려 흔들릴 수 있다(원하는 속도를 유지하는 제어를 뒤에서 다룬다).

아래 그림은 스텝 4에서 바닥 저항이 갑자기 커졌을 때 개루프와 폐루프가 어떻게 다르게 반응하는지 보여준다. 이 그림에 쓰인 숫자는 뒤에 나오는 완성 코드를 그대로 실행한 결과다.

폐루프는 부하가 늘어도 목표 속도로 다시 수렴하지만 개루프는 낮아진 속도에 머문다
개루프와 폐루프 비교
항목개루프폐루프
센서 사용쓰지 않는다매 주기 사용한다
외란 대응못 한다(그대로 밀어붙인다)오차만큼 스스로 보정한다
구현 난이도낮다센서·주기·이득 설계가 필요하다
대표 사례정해진 시간만큼 전진목표 속도를 유지하는 주행

소프트웨어 구성도

실제 로봇 프로그램에서는 감지·판단·구동이 각각 함수나 모듈로 나뉘고, 이 셋을 정해진 주기(예: 초당 50번)로 반복해서 부르는 반복문이 프로그램의 중심을 이룬다. 이 장의 코드에서는 실제 시간 대신 "스텝"이라는 정수로 한 번의 제어 주기를 나타낸다. 뒤에서 엔코더 같은 실제 센서를 다룰 때는 이 스텝 하나하나가 실제 경과 시간(초)에 대응한다.

이 구조를 코드로 옮기면 대체로 다음 모양이 된다. 센서 읽기 함수, 목표와 측정값으로 명령을 계산하는 함수, 그 명령을 하드웨어에 내보내는 함수를 분리해 두면, 나중에 센서나 모터를 실제 하드웨어로 바꿔도 판단 로직은 그대로 재사용할 수 있다. 반대로 이 셋을 한 함수에 뒤섞어 놓으면, 시뮬레이션에서 검증한 판단 로직을 실제 하드웨어에 옮길 때 다시 뜯어고쳐야 한다.

센서·제어기·구동기가 하는 일
구성요소하는 일이 장 예제에서의 자리
센서로봇이나 세상의 상태를 수치로 바꾼다측정값을 잡음 없이 그대로 가정
제어기목표와 측정값을 비교해 다음 명령을 정한다run_closed_loop 안의 오차 계산
구동기명령을 실제 힘·속도로 바꾼다load_factor로 흉내 낸 모터와 바닥

완성 코드

다음은 왼쪽 바퀴 모터 하나를 예로 들어, 개루프와 폐루프가 바닥 저항 변화에 어떻게 다르게 반응하는지 비교하는 프로그램이다. 파일 이름은 robot_loop_demo.py로 한다.

"""2바퀴 차동 구동 로봇의 왼쪽 바퀴 하나를 예로 들어
개루프 제어와 폐루프 제어가 부하 변화에 어떻게 다르게 반응하는지 비교한다."""

TARGET_SPEED = 1.0       # 목표 속도(m/s)
GAIN = 0.5                # 폐루프 보정 이득
STEPS = 8                  # 총 제어 스텝 수
DISTURBANCE_STEP = 4        # 이 스텝부터 바닥 저항이 커진다(예: 카펫 구간 진입)


def load_factor(step):
    """구동 명령 대비 실제로 나오는 속도의 비율.
    DISTURBANCE_STEP 이후로는 바닥 저항이 커져 비율이 떨어진다."""
    return 0.75 if step < DISTURBANCE_STEP else 0.5


def run_open_loop():
    """감지·판단 없이 명령을 한 번 내리고 그대로 미는 개루프 제어."""
    command = TARGET_SPEED
    return [command * load_factor(step) for step in range(STEPS)]


def run_closed_loop():
    """매 스텝 실제 속도를 측정해 목표와의 차이만큼 명령을 다시 내리는 폐루프 제어."""
    command = TARGET_SPEED
    speeds = []
    for step in range(STEPS):
        actual_speed = command * load_factor(step)
        speeds.append(actual_speed)
        error = TARGET_SPEED - actual_speed
        command = command + GAIN * error
    return speeds


def main():
    open_speeds = run_open_loop()
    closed_speeds = run_closed_loop()

    print(f"목표 속도: {TARGET_SPEED:.2f} m/s (스텝 {DISTURBANCE_STEP}부터 바닥 저항 증가)")
    print()
    print(f"{'스텝':>4} {'개루프(m/s)':>12} {'폐루프(m/s)':>12}")
    for step in range(STEPS):
        print(f"{step:>4} {open_speeds[step]:>12.3f} {closed_speeds[step]:>12.3f}")

    open_error = abs(TARGET_SPEED - open_speeds[-1])
    closed_error = abs(TARGET_SPEED - closed_speeds[-1])
    print()
    print(f"마지막 스텝 오차 - 개루프: {open_error:.3f} m/s, 폐루프: {closed_error:.3f} m/s")


if __name__ == "__main__":
    main()

줄별 해설

상단의 네 상수 중 DISTURBANCE_STEP이 이 예제의 핵심이다. 스텝 4부터 바닥 저항이 커진다고 가정해, 도중에 조건이 바뀌는 상황을 흉내 낸다.

load_factor(step)은 센서도 제어기도 아니다. "명령을 냈을 때 세상이 실제로 어떻게 반응하는가"를 흉내 내는 함수로, 뒤에서 실제 모터와 바닥으로 대체될 부분이다.

run_open_loop()은 명령을 처음에 딱 한 번(command = TARGET_SPEED) 정하고, 그 값을 STEPS만큼 그대로 재사용한다. 판단을 다시 하는 코드가 없으므로 개루프의 정의 그대로다.

run_closed_loop()은 반복문 안에서 매 스텝마다 actual_speed를 측정한 값으로 취급하고, error = TARGET_SPEED - actual_speed로 오차를 계산한 뒤 command를 갱신한다. 이 세 줄이 감지(측정값을 받는다)·판단(오차로 다음 명령을 정한다)·구동(다음 반복에서 그 명령을 다시 쓴다)이 한 바퀴 도는 부분이다.

main()은 두 결과를 표로 나란히 출력하고, 마지막 스텝의 오차를 비교해 개루프가 저항 변화 이후 계속 목표에서 벗어나 있는 반면 폐루프는 다시 목표에 가까워졌음을 보여준다.

실행 결과

$ python3 robot_loop_demo.py
목표 속도: 1.00 m/s (스텝 4부터 바닥 저항 증가)

  스텝     개루프(m/s)     폐루프(m/s)
   0        0.750        0.750
   1        0.750        0.844
   2        0.750        0.902
   3        0.750        0.939
   4        0.500        0.641
   5        0.500        0.731
   6        0.500        0.798
   7        0.500        0.849

마지막 스텝 오차 - 개루프: 0.500 m/s, 폐루프: 0.151 m/s

실무에서 자주 틀리는 것

피드백 없는 개루프로 끝내기

센서를 준비해 놓고도 정작 판단 코드에서는 쓰지 않는 경우가 있다.

# 틀린 코드
command = TARGET_SPEED
for step in range(STEPS):
    send_to_motor(command)   # 측정 없이 매번 같은 값만 내보낸다
# 고친 코드
command = TARGET_SPEED
for step in range(STEPS):
    send_to_motor(command)
    measured = read_speed_sensor()
    command = command + GAIN * (TARGET_SPEED - measured)

판단 로직과 구동 코드를 한 함수에 뒤섞기

오차 계산과 하드웨어 제어가 한 함수 안에 있으면, 시뮬레이션에서 검증한 판단 로직을 실제 보드로 옮길 때 통째로 다시 손봐야 한다.

# 틀린 코드
def drive_motor(target, sensor_value):
    error = target - sensor_value
    pwm = int((target + 0.5 * error) * 255)
    set_pwm_pin(pwm)   # 판단과 구동이 한 함수에 섞여 있다
# 고친 코드
def decide(target, measured, command):
    return command + 0.5 * (target - measured)

def actuate(command):
    set_pwm_pin(int(command * 255))

오차를 한 번만 보정하고 반복하지 않기

반복문 밖에서 오차를 딱 한 번 계산해 명령에 반영하고, 이후로는 다시 측정하지 않는 코드는 폐루프처럼 보이지만 실제로는 개루프다.

# 틀린 코드
error = TARGET_SPEED - read_speed_sensor()
command = TARGET_SPEED + 0.5 * error
for step in range(STEPS):
    send_to_motor(command)   # 이후로는 다시 측정하지 않는다
# 고친 코드
command = TARGET_SPEED
for step in range(STEPS):
    send_to_motor(command)
    measured = read_speed_sensor()
    command = command + 0.5 * (TARGET_SPEED - measured)

센서 값을 검증 없이 그대로 믿기

센서가 끊기거나 이상치를 내보내는 경우까지 고려하지 않으면, 잘못된 측정값 하나가 명령을 크게 흔들 수 있다.

# 틀린 코드
measured = read_speed_sensor()
command = command + GAIN * (TARGET_SPEED - measured)
# 고친 코드
measured = read_speed_sensor()
if measured is not None and measured >= 0:
    command = command + GAIN * (TARGET_SPEED - measured)

한눈에 보기

이 장 코드의 함수와 역할
함수역할소속 단계
load_factor(step)부하에 따라 실제 속도 비율을 정한다세상(환경) 흉내
run_open_loop()명령을 한 번 정해 그대로 사용한다개루프
run_closed_loop()측정값과 목표의 차이로 명령을 매번 갱신한다폐루프(감지·판단·구동)
main()두 결과를 표로 비교하고 오차를 출력한다실행·보고

연습 문제

  1. run_closed_loop 안에는 있고 run_open_loop 안에는 없는 코드 한 줄을 찾아, 그 줄이 왜 "판단" 단계에 해당하는지 설명하라.
  2. DISTURBANCE_STEP을 4 대신 6으로 바꾸면 run_open_loop이 반환하는 마지막(스텝 7) 속도 값이 달라지는가? 코드를 손으로 따라가며 답하고 이유를 설명하라.
  3. GAIN을 0.5에서 1.5로 올리면 스텝0에서 스텝1로 넘어갈 때 command 값이 어떻게 달라지는지, command = command + GAIN * (TARGET_SPEED - actual_speed) 식을 이용해 손으로 계산하라.
  4. 이 장 예제 코드에서 measured_speed 역할은 실제로 무엇으로 대체되어 있는가? 그렇게 단순화했을 때 실제 로봇과 달라지는 점을 한 가지 적어라.

정답과 해설

1. error = TARGET_SPEED - actual_speed와 그다음 줄 command = command + GAIN * error가 개루프에는 없다. 이 두 줄은 측정값과 목표의 차이를 계산해 다음 명령을 바꾸므로 "측정값을 보고 무엇을 할지 정하는" 판단 단계에 해당한다. run_open_loop은 처음 정한 command를 STEPS 동안 그대로 재사용할 뿐, 판단을 다시 하지 않는다.

2. 달라지지 않는다. run_open_loop은 command를 TARGET_SPEED로 고정한 채 매 스텝 load_factor(step)만 곱한다. DISTURBANCE_STEP을 6으로 바꿔도 스텝 7은 여전히 저항이 커진 이후 구간(step >= DISTURBANCE_STEP)에 속하므로 load_factor(7)은 그대로 0.5이고, 마지막 속도는 1.0 * 0.5 = 0.500으로 같다. 개루프는 저항이 언제부터 바뀌었는지와 무관하게 그 스텝의 조건만으로 계산되기 때문이다.

3. c0 = 1.0, actual0 = c0 * 0.75 = 0.75, error0 = 1.0 - 0.75 = 0.25다. 원래 GAIN(0.5)에서는 c1 = 1.0 + 0.5 * 0.25 = 1.125이고, GAIN을 1.5로 올리면 c1 = 1.0 + 1.5 * 0.25 = 1.375가 된다. 이득을 세 배로 올리면 같은 오차를 훨씬 크게 반영해 명령이 더 빨리 올라간다. 다만 이렇게 이득을 성급하게 키우면 목표를 지나쳐 흔들릴 수 있다는 점은 뒤에서 원하는 속도를 유지하는 제어를 다룰 때 다시 짚는다.

4. measured_speed 자리에는 잡음 없는 actual_speed 값을 그대로 사용했다. 즉 센서가 오차 없이 완벽하게 측정한다고 가정한 것이다. 실제 엔코더는 분해능 한계나 전기적 잡음 때문에 측정값이 조금씩 흔들리므로, 이 가정을 걷어내면 command도 매 스텝 미세하게 흔들리게 된다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.