Devin.KR

종합 실습 - 창고 로봇 시뮬레이터

개발자KR 조회 7

이 장에서 배우는 것

창고 로봇이 물건을 운반하려면 자신의 위치를 추정하고, 선반을 피해 경로를 찾고, 그 경로를 따라 움직여야 한다. 각 기능을 따로 실행했을 때 정상으로 보여도 연결한 뒤에는 문제가 생긴다. 지도 좌표와 제어 좌표가 다르거나, 센서 측정 시점이 어긋나거나, 도착 판정에 실제 위치를 몰래 사용하는 일이 대표적이다. 이 장에서는 작은 시뮬레이터를 만들어 이러한 연결 조건을 명시하고 운반 임무를 끝까지 실행한다.

앞 장에서 다룬 여러 로봇의 조율은 제외하고 로봇 한 대와 운반 작업 하나에 집중한다. 지도 작성이나 필터 유도 과정을 반복하지 않고, 이미 배운 기능 사이에서 어떤 정보를 주고받는지 살핀다. 화면에 움직임을 그리는 대신 수치 기록으로 성공 여부와 오차, 소요 시간, 장애물과의 간격을 확인한다.

  • 실제 상태, 추정 상태, 계획 경로를 분리해 모듈 간 연결 규칙을 정한다.
  • 점유 격자 지도와 A*를 연결해 집하 지점과 배송 지점을 차례로 방문한다.
  • 잡음이 있는 위치추정 결과로 목표점을 추종하고 적재·하역 상태를 전환한다.
  • 운반 성공률과 위치 오차, 소요 시간, 안전 여유를 서로 다른 지표로 평가한다.

문제 상황

창고 중앙에 세로로 긴 선반이 있다. 로봇은 선반 왼쪽에서 출발해 오른쪽 집하 지점에서 상자를 싣고, 같은 통로의 배송 지점으로 이동해야 한다. 선반을 가로지르는 직선은 사용할 수 없다. 지도에서 빈 칸만 연결해 우회 경로를 만들고, 경로를 미터 단위 목표점으로 바꾸어 제어기에 넘겨야 한다.

이 창고에는 지도 기준의 절대 위치를 관측하는 표식 센서가 있다고 가정한다. 센서는 정확한 위치를 주지 않으며, 이동량을 알려 주는 주행계에도 잡음이 있다. 로봇은 두 정보를 합친 추정 위치로 움직인다. 시뮬레이터 내부에는 실제 위치가 존재하지만, 제어기에는 전달하지 않는다. 실제 위치는 센서 측정값을 생성하고 성능을 평가할 때만 사용한다.

예제의 로봇은 평면 속도 명령을 받는 전방향 이동 플랫폼으로 단순화한다. 자세와 바퀴 회전은 상태에서 제외한다. 기본서의 차동 구동 로봇에 연결하려면 평면 속도를 그대로 바퀴에 보내면 안 된다. 자세를 포함한 운동 모델과 그에 맞는 추종 제어기가 필요하다. 여기서는 구동 방식의 세부사항보다 위치추정부터 임무 완료까지의 데이터 흐름을 검증한다.

지도는 미리 주어진 정적 점유 격자다. 라이다로 지도를 갱신하거나 사람이 통로를 막는 상황은 포함하지 않는다. 이 범위를 분명히 해야 운반 성공이라는 결과를 동적 창고 전체에 대한 성능으로 넓혀 해석하지 않게 된다.

모듈을 연결하는 좌표와 시간의 약속

지도 한 칸의 한 변은 1 m다. 칸 좌표는 정수 쌍 (x, y)이며 배열에서는 grid[y, x]로 접근한다. 배열 행 번호가 증가하는 방향을 세계 좌표의 양의 y 방향으로 정한다. 칸 (2, 4)의 중심은 미터 좌표 (2.5, 4.5)다. 지도 조회와 물리량 계산 사이에서 이 변환을 한 곳에서 수행한다.

모듈 사이에서 전달하는 값과 사용 범위
값형태와 단위생성 위치사용 위치
점유 지도불리언 2차원 배열창고 설정A*, 충돌 평가
계획 경로정수 칸 좌표 목록A*목표점 생성
추정 위치실수 2개, m位置 필터추종 제어, 도착 판정
평면 속도실수 2개, m/s추종 제어실제 이동,予측 계산
실제 위치실수 2개, m시뮬레이션 세계센서 생성, 평가

시간 간격은 0.2 s로 고정한다. 한 번의 갱신에서는 이전 추정 위치로 속도를 정하고, 실제 로봇을 움직이고, 이동량과 절대 위치를 측정한 뒤, 추정 위치를 갱신한다. 마지막으로 같은 시각의 실제 위치와 추정 위치를 비교한다. 순서를 바꾸어 이전 추정값과 이동 이후 실제값을 비교하면 센서 오차에 이동 거리가 섞인다.

필터의 상태는 x와 y 위치다. 두 축의 잡음이 독립이고 분산도 같으므로 공분산 행렬 대신 축 하나의 분산 p를 저장한다. 주행계 이동량을 추정 위치에 더한 뒤 분산에 Q를 더한다. 절대 위치 측정의 분산을 R이라 하면 이득은 k = p / (p + R)이다. 같은 이득으로 두 좌표를 보정한다.

잡음은 고정한 난수 생성기에서 뽑는다. 이동량에는 축마다 ±0.004 m, 절대 위치에는 ±0.008 m 범위의 균등 잡음을 넣는다. 균등분포의 분산이 범위 반폭의 제곱을 3으로 나눈 값이므로 코드의 Q와 R도 그에 맞춘다. 정규분포를 가정한 사후분포 해석까지 하지는 않지만, 이 선형 모델에서는 해당 공분산을 사용하는 선형 최소분산 추정을 적용할 수 있다.

제어기는 추정 위치를 사용하고 실제 위치는 센서 생성과 평가에 사용한다

그림의 목표점은 계획 경로에서 나온다. 제어기에는 실제 위치로 향하는 입력선이 없다. 구현에서도 이 경계를 유지한다. 같은 객체 안에 실제 위치와 추정 위치를 저장하더라도 제어식을 작성할 때는 추정 위치만 참조해야 한다. 이 작은 규칙이 시뮬레이션 결과를 읽는 기준이 된다.

경로를 운반 임무로 바꾸기

A*는 상하좌우 네 이웃을 연결하고 한 칸 이동 비용을 1로 둔다. 휴리스틱은 목표까지의 맨해튼 거리다. 같은 우선순위의 후보가 생기면 삽입 순서로 처리하도록 증가하는 번호를 함께 저장한다. 난수 시드뿐 아니라 탐색의 동률 처리도 정해야 같은 경로를 반복해서 얻기 쉽다.

이 예제에서 출발점은 (2, 4), 집하점은 (8, 4), 배송점은 (8, 7)이다. 선반은 x가 5이고 y가 2부터 6인 칸을 차지한다. 출발점에서 집하점까지는 12개 변을 지나고, 집하점에서 배송점까지는 3개 변을 지난다. 따라서 계획 경로 길이는 15 m다. 집하점을 먼저 방문해야 하므로 배송점에 가까운 곳을 중간에 지나더라도 작업 순서는 바뀌지 않는다.

각 경로 변을 열 조각으로 나누면 목표점 사이 거리는 0.1 m다. 이를 0.2 s마다 하나씩 공급하므로 기준 이동 속도는 0.5 m/s다. 제어기는 현재 목표점과 추정 위치의 차이를 시간 간격으로 나누어 속도를 구하고, 속력의 상한을 0.8 m/s로 제한한다. 이동 모델과 제어 모델이 같을 때 한 번의 갱신으로 목표점에 도달하려는 비례 제어다.

이 목표점열은 모퉁이에서 방향이 곧바로 바뀐다. 가속도 제한이나 방향 전환 시간을 표현하지 않으므로 실제 구동기의 주행 시간을 예측하는 데에는 사용할 수 없다. 경로와 제어기의 연결을 확인하기 위한 기준 입력으로 사용한다. 또한 목표점은 시각에 따라 넘어간다. 로봇이 느려졌을 때 다음 목표를 기다리는 방식이 아니므로 구간 마지막에 도착 오차를 별도로 검사한다.

임무 상태는 집하 이동, 적재, 배송 이동, 하역, 완료 순서다. 이동 구간이 끝나면 추정 위치가 목적지 중심에서 0.1 m 이내인지 확인한다. 조건을 만족하지 못하면 적재나 하역을 수행하지 않고 예외로 종료한다. 적재와 하역은 각각 1 s 동안 속도 명령을 0으로 유지하며, 그동안에도 센서와 위치추정은 갱신한다.

로봇의 반지름은 0.2 m다. 일반 지도라면 로봇 크기를 반영한 장애물 확장이 필요하지만, 이 지도에서는 칸 중심을 연결하는 경로와 장애물 사이에 최소 0.5 m의 중심 간격이 있다. 제한된 위치 오차를 고려해도 여유가 남는 설정이다. 따라서 예제의 A*에는 추가 확장을 넣지 않고 실제 위치에서 계산한 간격으로 이 조건을 확인한다. 이 판단을 더 좁은 통로에 그대로 적용해서는 안 된다.

성공과 성능을 따로 측정하기

임무 성공은 상자를 실은 뒤 내려놓았고, 충돌 없이 배송점에 도착했는지로 판단한다. 그러나 성공한 두 실행의 품질이 같다는 뜻은 아니다. 위치추정 오차가 커도 통로가 넓으면 성공할 수 있고, 위치추정이 정확해도 서비스 시간이 길면 운반 효율은 낮아진다. 기능별 지표를 함께 기록해야 변경의 영향을 구별할 수 있다.

이번 실행에서 측정하는 성능 지표의 의미
지표계산 대상단위해석할 때의 조건
위치추정 RMSE실제 위치와 추정 위치m같은 시각의 값을 비교한다
목표점 RMSE이동 후 실제 위치와 해당 목표점m이동 중 표본만 사용한다
최소 안전 여유장애물까지 거리에서 반지름을 뺀 값m이동 선분도 검사한다
완료 시간모든 갱신 횟수와 시간 간격s적재·하역 시간을 포함한다

평균제곱근오차(RMSE)는 각 시각의 2차원 오차 벡터 길이를 제곱하고, 그 평균의 제곱근을 취한다. 좌표 성분을 모두 펼쳐 평균 내는 계산과는 값이 다르다. 이 장에서는 위치 벡터 전체의 오차를 나타내는 전자를 사용한다. 위치추정 오차는 정지 시간을 포함하고, 목표점 오차는 이동 시간만 포함한다.

안전 여유는 로봇 중심에서 점유 칸의 사각형까지 잰 최단거리에서 로봇 반지름을 뺀 값이다. 이동 전후 위치만 검사하면 두 위치 사이의 장애물을 놓칠 수 있다. 코드에서는 한 이동 선분을 20개 구간으로 나누어 검사하고, 표본 간격의 절반을 측정 최솟값에서 추가로 뺀다. 장애물까지의 거리는 이동 거리보다 빠르게 줄어들 수 없으므로 이 값은 선분 전체의 안전 여유에 대한 보수적인 하한이 된다.

출력에는 원시 오차의 긴 소수 대신 고정 폭의 구간을 표시한다. 원시 값은 프로그램 내부에서 그대로 계산하며, 표시만 위치 오차는 0.05 m, 안전 여유는 0.25 m 폭으로 묶는다. 운영 기준을 통과했는지 보기에는 편하지만, 같은 구간 안에서 생긴 작은 개선을 비교하기에는 부족하다. 미세한 조정에서는 원시 값도 별도로 출력해야 한다.

완료 시간은 집하 이동 24초와 배송 이동 6초에 적재와 하역 각 1초를 더한 32초다

한 번의 실행에서 성공률이 1/1이라고 해서 다양한 잡음과 지도에서 높은 성공률을 보장하지는 않는다. 이 실행은 기능 연결을 재현하는 기준 사례다. 이후 비교에서는 지도, 출발점, 작업 순서, 잡음 조건을 고정한 채 바꾸려는 요소 하나를 조정하고, 별도 실험에서는 여러 시드와 작업에 걸친 분포를 확인한다.

완성 코드

다음 프로그램을 warehouse_sim.py로 저장한다. 외부 데이터 파일이나 그래픽 창을 사용하지 않으며, 표준 라이브러리와 numpy만 필요하다. 대괄호 번호는 아래 해설에서 찾기 위한 구간 표시다.

import heapq
import itertools
import math
import numpy as np

# [01] 거리 단위는 m, 시간 단위는 s다.
DT = 0.2
PARTS = 10
VMAX = 0.8
RADIUS = 0.2
ODO_HALF = 0.004
OBS_HALF = 0.008
Q = ODO_HALF ** 2 / 3.0
R = OBS_HALF ** 2 / 3.0
START = (2, 4)
PICK = (8, 4)
DROP = (8, 7)


# [02] True는 점유 칸이다.
def make_map():
    grid = np.zeros((9, 12), dtype=bool)
    grid[0, :] = True
    grid[-1, :] = True
    grid[:, 0] = True
    grid[:, -1] = True
    grid[2:7, 5] = True
    return grid


def center(cell):
    return np.asarray(cell, dtype=float) + 0.5


# [03] 네 방향 격자 A*다.
def astar(grid, start, goal):
    height, width = grid.shape

    def free(cell):
        x, y = cell
        return 0 <= x < width and 0 <= y < height and not grid[y, x]

    def heuristic(cell):
        return abs(cell[0] - goal[0]) + abs(cell[1] - goal[1])

    if not free(start) or not free(goal):
        raise ValueError("출발점과 목적지는 빈 칸이어야 한다")

    serial = itertools.count()
    queue = [(heuristic(start), 0, next(serial), start)]
    cost = {start: 0}
    parent = {start: None}
    directions = ((1, 0), (0, 1), (-1, 0), (0, -1))

    while queue:
        _, g, _, current = heapq.heappop(queue)
        if g != cost.get(current):
            continue
        if current == goal:
            path = []
            while current is not None:
                path.append(current)
                current = parent[current]
            return path[::-1]

        for dx, dy in directions:
            nxt = (current[0] + dx, current[1] + dy)
            ng = g + 1
            if free(nxt) and ng < cost.get(nxt, math.inf):
                cost[nxt] = ng
                parent[nxt] = current
                heapq.heappush(
                    queue, (ng + heuristic(nxt), ng, next(serial), nxt)
                )

    raise RuntimeError("연결된 경로가 없다")


# [04] 점에서 점유 사각형까지의 거리를 계산한다.
def point_clearance(position, boxes):
    gap = np.maximum(
        np.maximum(boxes - position, position - (boxes + 1.0)), 0.0
    )
    return float(np.min(np.linalg.norm(gap, axis=1))) - RADIUS


# [05] 이동 선분 전체의 안전 여유 하한을 계산한다.
def segment_clearance(begin, end, boxes):
    subdivisions = 20
    minimum = math.inf
    for j in range(subdivisions + 1):
        point = begin + (end - begin) * (j / subdivisions)
        minimum = min(minimum, point_clearance(point, boxes))
    spacing = float(np.linalg.norm(end - begin)) / subdivisions
    return minimum - spacing / 2.0


class Simulator:
    # [06] 실제 상태와 추정 상태를 따로 보관한다.
    def __init__(self, grid):
        self.rng = np.random.Generator(np.random.PCG64(2026))
        self.true = center(START)
        self.estimate = self.true.copy()
        self.p = R
        ys, xs = np.nonzero(grid)
        self.boxes = np.column_stack((xs, ys)).astype(float)
        self.ticks = 0
        self.est_sq = []
        self.track_sq = []
        self.min_clearance = point_clearance(self.true, self.boxes)
        self.collision = False
        self.loaded = False

    def step(self, target=None):
        # [07] 제어에는 추정 위치만 사용한다.
        velocity = np.zeros(2)
        if target is not None:
            velocity = (target - self.estimate) / DT
            speed = float(np.linalg.norm(velocity))
            if speed > VMAX:
                velocity *= VMAX / speed

        # [08] 실제 이동과 두 종류의 센서 측정을 만든다.
        before = self.true.copy()
        displacement = velocity * DT
        self.true += displacement
        odometry = displacement + self.rng.uniform(
            -ODO_HALF, ODO_HALF, size=2
        )
        observation = self.true + self.rng.uniform(
            -OBS_HALF, OBS_HALF, size=2
        )

        # [09] 이동량 예측 뒤 절대 위치로 보정한다.
        self.estimate += odometry
        self.p += Q
        gain = self.p / (self.p + R)
        self.estimate += gain * (observation - self.estimate)
        self.p *= 1.0 - gain

        # [10] 같은 시각의 값을 평가하고 선분 간격을 검사한다.
        error = self.estimate - self.true
        self.est_sq.append(float(error @ error))
        if target is not None:
            tracking = self.true - target
            self.track_sq.append(float(tracking @ tracking))

        clearance = segment_clearance(before, self.true, self.boxes)
        self.min_clearance = min(self.min_clearance, clearance)
        self.collision = self.collision or clearance <= 0.0
        self.ticks += 1

    # [11] 칸 중심 사이에 시각별 목표점을 만든다.
    def follow(self, path):
        for first, second in zip(path, path[1:]):
            begin = center(first)
            end = center(second)
            for j in range(1, PARTS + 1):
                target = begin + (end - begin) * (j / PARTS)
                self.step(target)

        estimated_error = np.linalg.norm(
            self.estimate - center(path[-1])
        )
        if estimated_error > 0.1:
            raise RuntimeError("추정 위치가 도착 허용 범위를 벗어났다")
        if self.collision:
            raise RuntimeError("안전 여유를 확보하지 못했다")

    # [12] 정지 중에도 위치추정 시간은 흐른다.
    def service(self):
        for _ in range(5):
            self.step()


def rmse(squared_errors):
    return math.sqrt(sum(squared_errors) / len(squared_errors))


# [13] 원시 값은 유지하고 출력만 일정한 폭으로 묶는다.
def band(value, width):
    lower = math.floor(value / width) * width
    return f"[{lower:.2f}, {lower + width:.2f}) m"


def main():
    # [14] 임무를 시작하기 전에 두 경로의 존재를 확인한다.
    grid = make_map()
    to_pick = astar(grid, START, PICK)
    to_drop = astar(grid, PICK, DROP)
    sim = Simulator(grid)
    states = []

    states.append("집하 이동")
    sim.follow(to_pick)
    states.append("적재")
    sim.service()
    sim.loaded = True

    states.append("배송 이동")
    sim.follow(to_drop)
    states.append("하역")
    if not sim.loaded:
        raise RuntimeError("내려놓을 물건이 없다")
    sim.service()
    sim.loaded = False
    states.append("완료")

    # [15] 실제 위치는 완료 후 평가에 사용한다.
    delivery_error = float(np.linalg.norm(sim.true - center(DROP)))
    success = (
        not sim.loaded
        and not sim.collision
        and delivery_error <= 0.1
    )
    planned_length = len(to_pick) + len(to_drop) - 2
    movement_time = len(sim.track_sq) * DT
    elapsed = sim.ticks * DT

    print("상태: " + " → ".join(states))
    print(f"경로 칸 수: 집하 {len(to_pick)}, 배송 {len(to_drop)}")
    print(f"계획 경로 길이: {planned_length:.1f} m")
    print(f"이동 시간: {movement_time:.1f} s")
    print(f"서비스 시간: {elapsed - movement_time:.1f} s")
    print(f"완료 시간: {elapsed:.1f} s")
    print(f"운반 성공: {int(success)}/1")
    print("위치 추정 RMSE 구간: " + band(rmse(sim.est_sq), 0.05))
    print("목표점 RMSE 구간: " + band(rmse(sim.track_sq), 0.05))
    print("배송 위치 오차 구간: " + band(delivery_error, 0.05))
    print("최소 안전 여유 하한 구간: " + band(sim.min_clearance, 0.25))


if __name__ == "__main__":
    main()

줄별 해설

[01] 단위와 실험 조건. DT는 센서와 제어기가 함께 갱신되는 간격이다. PARTS는 경로 변 하나에 배정한 이동 횟수다. 두 값을 따로 바꾸면 기준 속도도 달라진다. Q와 R은 표준편차가 아니라 분산이며, 여기서는 각 갱신의 이동량 오차와 절대 위치 오차에 대응한다.

[02] 지도와 중심 좌표. 바깥 테두리를 점유 상태로 만들어 창고 경계를 표현한다. grid[2:7, 5]에서 끝 인덱스 7은 포함하지 않으므로 선반은 다섯 칸이다. center()의 0.5는 칸의 왼쪽 아래 모서리를 중심으로 옮긴다. 실제 위치를 초기화할 때와 목표점을 만들 때 같은 함수를 사용한다.

[03] 탐색과 경로 복원. free()는 배열 범위부터 검사하므로 음수 인덱스로 반대쪽 경계를 읽지 않는다. 우선순위 큐에는 예상 총비용, 누적비용, 삽입 번호, 칸 좌표가 들어간다. 더 짧은 경로가 발견된 뒤 남아 있는 오래된 큐 항목은 누적비용 비교로 건너뛴다. 목적지에 도달하면 부모를 거꾸로 따라가고 목록을 뒤집는다. 반환 경로에는 출발점과 목적지가 모두 포함된다.

[04] 장애물을 사각형으로 평가. boxes의 각 행은 점유 칸의 왼쪽 아래 좌표다. 점이 사각형의 x 범위 안에 있으면 x 방향 간격은 0이고, 바깥이면 가까운 경계까지의 거리를 사용한다. y 방향도 같은 방식이다. 두 간격의 벡터 길이가 사각형까지의 거리이며, 여러 점유 칸 가운데 최솟값을 선택한다.

[05] 이동 중간까지 포함한 하한. 표본 간격이 h일 때 선분 위의 어느 점이든 가장 가까운 표본까지의 거리는 h/2 이하다. 따라서 표본에서 측정한 최소 여유에서 h/2를 빼면 실제 최소 여유보다 큰 값을 보고할 위험을 줄일 수 있다. 이 계산에서 0 이하가 나오면 충돌 가능성을 보수적으로 처리한다. 정지 중에는 h가 0이므로 추가 차감도 없다.

[06] 실험의 초기 조건. 난수 생성기는 시뮬레이터마다 한 번 만든다. 실제 위치와 추정 위치는 값은 같지만 서로 다른 배열이다. 처음에는 위치를 안다고 가정하고 이후 센서 잡음으로 차이가 생기게 한다. p = R은 필터에 부여한 초기 불확실성으로, 실제 초기 오차가 0이라는 사실과 구별한다.

[07] 속도 명령. 목표점이 없으면 정지 명령을 낸다. 목표점이 있으면 추정 위치와의 차이를 DT로 나눈다. 속력 제한은 벡터 전체에 같은 비율을 곱하므로 이동 방향을 유지한다. x와 y 성분을 각각 제한하면 대각 방향의 총속력이 상한보다 커질 수 있다.

[08] 세계와 센서. 실제 이동에는 별도의 구동 잡음을 넣지 않는다. 주행계가 알려 주는 이동량에만 잡음을 추가한다. 표식 센서는 이동 이후 실제 위치를 관측한다. 이 가정 덕분에 위치추정 오차가 추종에 미치는 영향을 분리해 볼 수 있다. 구동 오차를 넣는 실험에서는 실제 변위와 예측에 사용하는 변위를 구분해야 한다.

[09] 필터 갱신. 주행계 변위를 더한 추정 위치가 예측값이다. 절대 위치 관측과 예측값의 차이에 이득을 곱해 보정한다. 보정 뒤 분산은 예측 분산의 1 - gain배다. 두 축의 상관관계나 다른 잡음 크기를 표현해야 한다면 이 스칼라 분산을 행렬로 바꾸어야 한다.

[10] 기록 시점. 필터 보정이 끝난 뒤 실제 위치와 비교한다. error @ error는 두 좌표 오차 제곱의 합이다. 목표점이 없는 서비스 시간에는 목표점 오차 표본을 추가하지 않는다. 충돌 상태는 한 번 참이 되면 계속 참으로 남으므로 이후 멀리 떨어졌다는 이유로 이전 위반이 사라지지 않는다.

[11] 이동과 도착 판정. zip(path, path[1:])은 연속한 경로 칸을 하나씩 묶는다. 각 변의 시작점은 이전 이동에서 이미 방문했으므로 j는 1부터 시작한다. 마지막 목표점을 처리한 뒤에는 추정 위치로 도착을 확인한다. 이 검사는 실제 위치를 사용하는 성능 평가와 다른 역할이다.

[12] 서비스 시간. 다섯 번의 정지 갱신이 1 s다. 이 모델에는 외력이 없으므로 실제 위치는 유지되지만 센서 측정값과 추정 위치는 계속 달라진다. 실제 시스템에서도 적재 중이라는 이유로 시간 관리나 센서 처리를 멈추면 다음 이동의 초기 조건이 어긋날 수 있다.

[13] 표시 구간. band()는 값이 속한 반열린 구간을 만든다. 예를 들어 [0.00, 0.05)는 0 이상 0.05 미만이다. 값 자체를 반올림해서 평가하는 것이 아니므로 판정 전에 작은 양수를 0으로 만드는 일은 없다.

[14] 임무 순서. 두 경로를 먼저 구하므로 배송 경로가 없는 상태에서 적재부터 시작하지 않는다. loaded는 작업 상태를 표현한다. 이 예제는 순서가 고정된 단일 임무라 함수 호출로 상태를 전환하지만, 상태별 행동과 기록은 구분한다.

[15] 결과 집계. 칸 수에서 각각 1을 빼야 이동한 변 수가 된다. 시간은 반복해서 실수를 더하는 대신 정수 갱신 횟수에 DT를 곱해 계산한다. 마지막 실제 위치는 평가에서만 사용한다. 추정 위치의 도착 판정은 통과했지만 실제 오차가 허용 범위를 벗어난 경우라면 운반 성공 값은 0이 된다.

실행 결과

numpy가 설치된 환경에서 다음 명령으로 문법 경고를 오류로 처리하며 바이트코드를 생성하고 프로그램을 실행한다. 컴파일 명령은 성공하면 출력하지 않는다. Python의 바이트코드 컴파일은 센서 모델이나 제어기의 동작을 검증하는 과정은 아니므로 이어지는 실행과 지표 확인이 함께 필요하다.

python3 -W error -m py_compile warehouse_sim.py
python3 -W error warehouse_sim.py

예상 출력은 다음과 같다. 같은 조건에서의 비교를 위해 시드와 생성기 종류를 코드에 고정했다. 출력은 작은 부동소수점 차이보다 넓은 성능 구간을 사용한다.

상태: 집하 이동 → 적재 → 배송 이동 → 하역 → 완료
경로 칸 수: 집하 13, 배송 4
계획 경로 길이: 15.0 m
이동 시간: 30.0 s
서비스 시간: 2.0 s
완료 시간: 32.0 s
운반 성공: 1/1
위치 추정 RMSE 구간: [0.00, 0.05) m
목표점 RMSE 구간: [0.00, 0.05) m
배송 위치 오차 구간: [0.00, 0.05) m
최소 안전 여유 하한 구간: [0.25, 0.50) m

집하 경로의 13칸은 12 m 이동을 뜻한다. 한 칸에 10회 갱신을 배정하므로 집하 이동은 24 s다. 배송 이동은 6 s이고 두 서비스에 2 s가 더해진다. 따라서 완료 시간 32 s는 컴퓨터가 프로그램을 계산하는 데 걸린 시간이 아니라 시뮬레이션 안에서 흐른 시간이다.

이 설정에서는 오차 구간에 들어가는 이유도 추적할 수 있다. 필터 이득은 대략 0.39 이상을 유지하며, 한 축의 추정 오차는 0.015 m 안쪽에 머무는 범위로 제한된다. 속력 제한이 걸리지 않는 한 이동 후 실제 위치와 목표점의 차이는 직전 추정 오차의 반대 부호가 된다. 위치추정 성능이 추종 오차로 연결되는 관계를 코드에서 확인할 수 있다.

안전 여유 출력은 최소 여유가 정확히 0.25 m라는 뜻이 아니다. 이동 선분에 대해 계산한 하한이 0.25 m 이상 0.50 m 미만이라는 뜻이다. 반면 운반 성공은 정해 둔 논리 조건의 결과다. 수치 구간과 성공 판정을 같은 종류의 정보로 읽지 않는 것이 중요하다.

실무에서 자주 틀리는 것

실제 위치로 제어해 추정 오차를 가린다

시뮬레이터에서 쉽게 접근할 수 있다는 이유로 실제 위치를 제어기에 넣으면 센서 성능이 나빠져도 추종 성능이 거의 바뀌지 않는다. 다음 첫 코드는 평가용 정보를 제어에 사용한다.

# 틀린 코드
velocity = (target - self.true) / DT

# 고친 코드
velocity = (target - self.estimate) / DT

실제 위치는 센서 생성과 평가에서 계속 필요하다. 변수 자체를 없애기보다 어느 기능이 읽을 수 있는지 구분하는 것이 해결책이다.

행·열 인덱스와 칸 중심을 혼동한다

칸 좌표를 그대로 배열 인덱스로 사용하면 x와 y가 뒤집힌다. 직사각형 지도에서는 범위 오류가 나기도 하고, 정사각형 지도에서는 오류 없이 다른 장애물을 읽기도 한다.

# 틀린 코드
occupied = grid[x, y]
target = np.array([x, y], dtype=float)

# 고친 코드
occupied = grid[y, x]
target = center((x, y))

단위가 1 m라 정수 칸 좌표와 미터 좌표가 비슷해 보여도 의미는 다르다. 중심 변환을 생략하면 목표점이 칸 경계로 옮겨져 장애물 여유가 줄어든다.

좌표 성분의 평균으로 위치 오차를 보고한다

오차 배열의 형태가 표본 수와 2로 이루어져 있을 때 모든 원소의 제곱 평균을 구하면 축별 오차를 합친 위치 오차보다 작게 나온다. 다음 코드는 이 장에서 정의한 위치 RMSE와 맞지 않는다.

# 틀린 코드: errors의 형태는 (표본 수, 2)다.
value = np.sqrt(np.mean(errors ** 2))

# 고친 코드: 각 시각의 위치 오차 제곱을 먼저 구한다.
value = np.sqrt(np.mean(np.sum(errors ** 2, axis=1)))

첫 계산도 좌표 성분 기준의 지표로 정의하면 사용할 수 있다. 문제는 계산식과 이름이 다른 경우다. 보고서에는 비교 대상과 집계 축을 함께 적어야 한다.

이동이 끝난 위치만 검사한다

한 번의 갱신에서 장애물을 가로질러 반대쪽 빈 공간으로 이동하면 끝점 검사는 통과할 수 있다. 특히 시간 간격이나 속력 상한을 키우었을 때 나타나기 쉽다.

# 틀린 코드
clearance = point_clearance(self.true, self.boxes)

# 고친 코드
clearance = segment_clearance(before, self.true, self.boxes)

이 예제는 한 갱신 동안 직선으로 움직인다고 가정하므로 선분 검사가 맞는다. 곡선 운동 모델로 바꾸면 실제 이동 곡선을 덮는 검사로 바꾸어야 한다. 안전 여유의 보수적인 하한이 0 이하라고 해서 충돌이 확정된 것은 아니지만, 안전하다고 통과시키지는 않는다.

한눈에 보기

창고 로봇 시뮬레이터의 연결 조건과 한계
구성 요소이번 구현확인할 조건
지도1 m 격자와 고정 선반배열은 y, x 순서로 읽는다
위치추정이동량 예측과 절대 위치 보정측정 시점과 분산 단위를 맞춘다
경로 계획네 방향 A* 두 구간적재 전에 배송 경로도 확인한다
추종 제어시간별 목표점에 대한 비례 제어추정 위치를 사용하고 속력을 제한한다
작업 상태이동, 적재, 이동, 하역도착 확인 뒤 작업을 전환한다
평가성공, 오차, 시간, 안전 여유단일 시드 결과의 범위를 구분한다

완성된 시스템을 이해하는 기준은 모듈의 개수보다 정보가 흐르는 방식이다. 좌표와 시각을 맞추고, 로봇이 사용할 수 있는 정보와 평가자가 아는 정보를 나누고, 결과를 여러 지표로 남기면 변경의 영향을 추적할 수 있다. 이 시뮬레이터는 그러한 확인을 반복할 수 있는 작은 기준 구현이다.

연습 문제

  1. 지도와 경로를 유지한 채 PARTS를 20으로 바꾼다. DT와 서비스 갱신 횟수는 유지한다. 기준 속도, 이동 시간, 완료 시간을 계산하고, 실제 결과에서 별도로 확인해야 할 항목을 설명한다.
  2. 두 RMSE의 원시 값을 소수점 여섯 자리로 출력하도록 수정한다. 출력 구간이 같아도 두 실행의 성능이 다를 수 있는 이유를 설명한다.
  3. 선반 설정을 grid[1:8, 5] = True로 바꾼다. 어떤 단계에서 실행이 중단되는지 설명하고, 경로가 없다는 결과를 운반 성공률에 포함할 때의 기준을 정한다.
  4. 시드 2026부터 2035까지 열 번의 임무를 실행하도록 구조를 바꾼다고 하자. 시드를 전달할 위치, 반복마다 새로 만들어야 하는 상태, 성공률과 완료 시간을 집계할 때의 주의점을 설명한다.

정답과 해설

  1. 한 칸을 20회에 나누어 이동하므로 목표점 간격은 0.05 m이고 기준 속도는 0.25 m/s다. 전체 이동 횟수는 15 × 20 = 300회이므로 이동 시간은 60 s다. 적재와 하역은 각각 다섯 번의 갱신을 유지하므로 서비스 시간은 2 s, 완료 시간은 62 s다. 이는 임무가 정상 완료됐을 때의 시간이다. 위치 오차, 도착 판정, 안전 여유는 새 실행에서 확인해야 한다. 이동 갱신이 늘면 난수 소비 순서도 달라지므로 이전 실행의 오차를 그대로 사용할 수 없다.

  2. main의 출력 부분에 다음 두 줄을 추가한다. 구간 표시는 같은 폭 안의 값을 한 범주로 묶기 때문에 작은 변화는 감춘다. 원시 값 비교에도 동일한 시드와 실험 조건이 필요하며, 여러 시드에서 반복되지 않는 작은 차이는 일반적인 개선으로 단정하지 않는다.

    print(f"위치 추정 RMSE 원시값: {rmse(sim.est_sq):.6f} m")
    print(f"목표점 RMSE 원시값: {rmse(sim.track_sq):.6f} m")
  3. 세로 선반이 위아래 경계까지 이어져 출발점과 집하점이 서로 다른 빈 공간 영역으로 분리된다. 첫 A* 호출이 후보를 모두 소진하고 경로가 없다는 예외를 낸다. 이 시점에는 시뮬레이터도 만들어지지 않았고 적재도 시작하지 않았다. 주어진 모든 작업의 처리 능력을 평가한다면 이 작업은 실패로 포함한다. 이동 가능한 작업에 대한 주행 성능만 평가한다면 제외할 수 있지만, 그때는 제외한 작업 수와 기준을 함께 보고해야 한다.

  4. 생성자에 seed 인자를 추가하고 np.random.PCG64(seed)에 전달한다. 각 반복에서 실제 위치, 추정 위치, 분산, 적재 여부, 충돌 상태, 시간, 오차 기록을 모두 새로 만든다. 정적 지도와 계획 경로는 같은 조건이라면 재사용할 수 있다. 성공률의 분모는 시도한 열 번이며, 예외로 중단된 실행도 미리 정한 기준에 따라 실패로 집계한다. 완료 시간은 성공한 실행의 통계인지 전체 시도의 종료 시간인지 구분해야 한다. 성공한 실행만 평균 내면서 실패한 실행의 비용을 숨기지 않도록 실패 수와 중단 시각도 별도로 남긴다.

댓글 0

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

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