Devin.KR

ROS 2 · 심화

QoS·tf2·행동·시스템 통합

시스템 통합 사례 - 배달 로봇 주행 스택 묶기

패키지 구성, launch 계층, 파라미터 관리, 장애 대응 점검표

개발자KR · 원고 갱신

이 장에서 배우는 것

두리가 실외 배달을 시작하려면 센서, 위치 추정, 경로 계획, 주행 제어가 같은 실행 조건을 공유해야 한다. 각 노드가 따로 실행된다는 사실만으로 시스템이 준비되었다고 볼 수 없다. 다른 지도 파일을 읽거나, 서로 다른 시간을 사용하거나, 오래된 상태 정보를 정상으로 판단하면 개별 기능이 동작해도 배달은 이어지지 않는다.

앞 장에서 지연과 보안을 살펴보았다. 이제 그 결과를 패키지 배치, 실행 순서, 설정 파일, 장애 대응 절차에 연결한다. 이 장의 실행 예제는 설정 검사와 장애 대응 모형에 집중한다. 실제 모터를 제어하지 않으며, 실외 주행을 검증한 프로그램으로 취급하지 않는다.

  • 변경 책임에 따라 패키지를 나누고 통합 패키지의 역할을 정한다.
  • 런치(launch) 파일을 상위 정책과 하위 기능으로 나눈다.
  • 파라미터의 출처와 덮어쓰기 순서를 고정한다.
  • 정보 만료와 복구 조건을 순수 Python 상태 모형으로 재현한다.
  • 장애를 발견했을 때 확인할 증거와 재출발 조건을 점검표로 만든다.

문제 상황

두리는 실내에서 정해진 목적지까지 이동했다. 실외 시험을 위해 위성항법 수신기와 새로운 바퀴 구동기를 붙이자 실행 명령이 늘어났다. 담당자마다 다른 터미널에서 노드를 실행했고, 어떤 날은 위치 추정이 준비되기 전에 주행 요청이 들어왔다. 지도 경로는 개발자 개인 디렉터리를 가리켰으며, 시험용 속도 설정이 어느 파일에 남았는지도 분명하지 않았다.

통신이 잠시 끊긴 뒤에는 다른 문제가 생겼다. 센서 정보가 다시 들어오자 상위 프로그램이 이전 배달을 계속하려 했다. 그러나 두리가 멈춘 사이 사람이 앞에 짐을 놓았을 수 있다. 정보 수신의 회복과 이동 재개의 허가는 별도로 판단해야 한다.

이 문제를 해결하려고 모든 노드를 하나의 파일에 나열하면 처음에는 실행이 편해진다. 시간이 지나면 하드웨어 교체, 장소 변경, 장애 복구 규칙이 같은 파일에서 충돌한다. 통합의 목표는 실행 명령을 짧게 만드는 데 그치지 않는다. 어떤 구성으로 시작했는지, 무엇을 준비 완료로 보는지, 실패하면 누가 이동을 중단하는지를 드러내는 데 있다.

패키지 경계와 launch 계층을 함께 설계한다

패키지는 기능 개수보다 변경 책임을 기준으로 나눈다. 바퀴 구동기를 교체할 때 지도 설정까지 수정해야 한다면 경계가 지나치게 얽혀 있다. 반대로 작은 설정 파일마다 별도 패키지를 만들면 의존 관계를 따라가는 비용이 커진다. 두리에서는 장치 연결, 로봇 모델, 주행 설정, 전체 조립을 서로 다른 책임으로 둔다.

두리 주행 시스템의 패키지별 변경 책임
패키지소유하는 내용변경 계기통합 경계
duri_hardware장치 연결과 구동기 실행센서·모터 교체장치 상태와 명령 인터페이스
duri_description로봇 모델과 장착 정보차체·센서 위치 변경모델 파일과 프레임 이름
duri_navigation주행 관련 설정과 지도 선택운행 장소·주행 정책 변경주행 기능의 실행 진입점
duri_bringup전체 실행과 현장 설정 선택운영 조합 변경운영자가 사용하는 실행 명령

통합 패키지는 다른 패키지의 구현을 복사하지 않는다. 하위 패키지가 제공하는 실행 진입점을 호출하고 필요한 인자를 전달한다. 센서 드라이버 내부의 재연결 방식은 장치 패키지가 관리하고, 그 장치를 이번 배포에서 실행할지는 통합 패키지가 결정한다.

상위 실행 파일은 공통 설정을 선택하고 하위 실행 파일은 각 기능의 노드를 조립한다

상위 실행 파일에는 로봇 이름, 현장 설정 파일, 시간 기준처럼 여러 기능이 공유하는 선택을 둔다. 하위 실행 파일에는 해당 기능의 노드와 구체적인 연결을 둔다. 같은 인자를 여러 단계에서 서로 다른 이름으로 바꾸면 추적이 어려우므로, 전달 과정에서는 이름과 의미를 유지한다.

파일에 적은 순서는 준비 완료 순서가 아니다. 프로세스가 시작되어도 장치 연결이나 초기 데이터 수신은 끝나지 않았을 수 있다. 고정된 대기 시간을 넣는 방식도 현장 부하가 달라지면 흔들린다. 실제 통합에서는 장치 연결, 위치 추정 유효성, 제어기 응답처럼 관측 가능한 조건을 사용한다. 이 장에서는 이러한 관측을 두 정보의 나이로 단순화한다.

완성 코드의 ROS 부분은 이 구조를 작게 보여 주는 설정 검사 패키지다. 상위 파일이 하위 파일을 포함하고, 하위 파일이 검사 노드를 실행한다. 위 표의 다른 패키지와 Nav2를 설치하거나 구동하지 않는다. 전체 주행 스택에 붙일 때는 같은 계층에 각 기능의 실행 진입점을 추가한다.

파라미터는 값과 출처를 함께 관리한다

설정이 여러 곳에 존재하는 것 자체가 문제는 아니다. 어느 값이 적용되는지 설명할 수 없는 상태가 문제다. 기본값, 현장 파일, 실행 인자의 우선순위를 정하고 같은 의미의 설정은 한 경로로만 전달해야 한다. 특히 시간 기준이 섞이면 정보의 나이를 비교하는 판단이 성립하지 않을 수 있다.

예제에서는 노드의 선언 기본값 위에 YAML 파일을 적용하고, 마지막으로 실행 인자의 시간 설정을 적용한다. 현장 파일은 속도 한계, 정보 만료 시간, 복구 확인 횟수를 소유한다. 시간 기준은 상위 실행 인자만 소유한다. Python 기본값은 직접 실행을 위한 기준이며, 운영 실행에서는 명시적인 현장 파일을 선택한다.

설정의 소유 위치와 최종 적용 규칙
설정소유 위치검사 조건운영 의미
max_speed_mps현장 YAML유한한 양수이동 허용 시 모형의 속도 상한
timeout_sec현장 YAML유한한 양수정보 나이에 허용하는 상한
recovery_samples현장 YAML1 이상의 정수연속 정상 관측의 필요 횟수
use_sim_time상위 실행 인자명시적인 논리형 변환ROS 노드의 시간 기준

값의 범위 검사는 단위 검사와 함께 생각해야 한다. 500이라는 숫자가 밀리초인지 초인지 모르면 양수 검사만으로는 잘못된 설정을 찾을 수 없다. 따라서 이름에 단위를 넣고, 장소별 허용 범위는 해당 운영 정책에서 추가로 제한한다. 이 예제의 양수 검사는 최소한의 형식 검사이며, 속도가 현장에 적합하다는 승인까지 뜻하지 않는다.

YAML의 /** 선택자는 파일을 전달받는 노드의 이름과 네임스페이스(namespace)가 바뀌어도 설정이 매칭되게 한다. 이 파일이 실행 중인 모든 노드에 자동 배포되는 것은 아니다. 예제에서는 검사 노드 하나에만 전달한다. 여러 종류의 노드에 같은 파일을 전달한다면 넓은 선택자보다 노드별 설정을 구분하는 편이 추적하기 쉽다.

설정 파일은 소스 디렉터리뿐 아니라 설치 결과에도 들어 있어야 한다. 운영 실행은 패키지의 설치 위치에서 파일을 찾는다. 현장 파일의 경로, 파일 내용의 해시, 소프트웨어 개정 식별자, 최종 적용값을 함께 보관하면 문제를 재현하기 쉬워진다. 예제 노드는 최종 적용값을 기록하며, 해시와 배포 식별자 수집은 운영 절차에서 추가한다.

장애 대응은 멈춤과 재출발을 나누어 설계한다

두리의 상태를 대기인 WAIT, 이동 허용인 RUN, 복구 대기인 HOLD로 나눈다. 시작할 때는 정상 관측이 정해진 횟수만큼 연속으로 들어와야 RUN으로 바뀐다. 이동 중 정보가 만료되면 HOLD로 들어가고 속도 상한을 0으로 만든다. HOLD에서는 정상 관측이 충분히 쌓여도 명시적인 재출발 요청이 있어야 RUN으로 돌아간다.

정보가 회복되어도 복구 대기 상태는 유지되며 정상 확인과 재출발 요청을 함께 만족해야 이동을 허용한다

예제에서 정보의 나이는 현재 판단 시점에서 마지막 유효 정보까지 지난 시간이다. 한계와 같은 값은 허용하고 한계를 초과하면 만료로 처리한다. 음수, 무한대, 숫자가 아닌 값도 정상 정보로 인정하지 않는다. 실제 시스템에서는 메시지 생성 시각의 오래됨과 수신 중단 시간을 구분해서 관측해야 한다. 서로 다른 시계의 값을 직접 빼지 않는다.

연속 정상 횟수는 데이터 자체의 정확성을 보장하지 않는다. 오래된 메시지를 새 관측으로 반복 계산하지 않도록 수신 식별자나 갱신 여부를 확인해야 한다. 또한 관측 주기가 달라지면 같은 횟수가 뜻하는 시간이 달라진다. 예제의 각 입력은 별도의 정상 여부 판단을 나타내며, 실제 적용에서는 횟수와 함께 필요한 유지 시간도 정한다.

상태 모형이 속도 상한 0을 반환하는 일과 로봇이 실제로 멈추는 일은 다르다. 실제 시스템에는 그 판단을 명령 경로에 반영하는 장치가 필요하다. 상위 프로그램 자체가 멈추었을 때도 구동기가 명령 만료를 감지해야 한다. 장애 대응 점검표에는 판단 주체뿐 아니라 명령 차단 위치와 정지 확인 방법도 포함한다.

장애 발생 시 수집할 증거와 복구 조건
관측한 상황즉시 대응확인할 증거재출발 조건
위치 정보 만료이동 허용 해제마지막 유효 시각과 시간 기준유효 정보 유지와 주변 확인
제어기 응답 만료명령 차단과 구동기 정지 확인명령·응답 기록과 장치 연결제어 경로 확인과 재출발 승인
설정 검사 실패실행 중단선택 파일과 적용값설정 수정 후 다시 검사
프로세스 반복 종료재시작 횟수 제한종료 원인과 반복 간격원인 조치와 기능 준비 확인

재시작은 프로세스를 다시 만들 뿐 이전 작업을 이어 가도 된다는 결정을 대신하지 않는다. 배달 목표를 재전송할지, 현재 위치에서 다시 계획할지, 작업을 취소할지는 운영 정책으로 남겨야 한다. 장애 중에도 진단 자료는 수집하되, 진단 프로그램의 생존 여부만으로 이동을 허용해서는 안 된다.

완성 코드

다음 파일을 duri_ws/src/duri_bringup 아래에 배치한다. 이 패키지는 Python 실행 파일을 설치하기 위해 ament_cmake를 사용한다. scripts, launch, config 디렉터리를 각각 만든다. 순수 Python 실행에는 첫 파일만 필요하고 외부 라이브러리는 필요하지 않다.

ROS 실행은 설정 검사 노드를 한 번 실행한 뒤 종료한다. 지속적인 감시나 이동 명령 발행은 하지 않는다. 이 구분을 통해 ROS가 없는 macOS와 Linux에서도 상태 전이와 설정 검사를 실행할 수 있다. ROS 부분은 Jazzy의 관련 패키지가 설치된 환경에서 사용하며, macOS에 ROS가 기본 제공된다고 가정하지 않는다.

scripts/config_probe.py

#!/usr/bin/env python3
import math
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class Config:
    max_speed_mps: float = 0.4
    timeout_sec: float = 0.5
    recovery_samples: int = 2

    def validate(self):
        for name in ("max_speed_mps", "timeout_sec"):
            value = getattr(self, name)
            if type(value) not in (int, float):
                raise ValueError(f"{name}: number required")
            if not math.isfinite(value) or value <= 0:
                raise ValueError(f"{name}: finite positive value required")
        if type(self.recovery_samples) is not int:
            raise ValueError("recovery_samples: integer required")
        if self.recovery_samples < 1:
            raise ValueError("recovery_samples: value must be at least 1")


class Gate:
    def __init__(self, config):
        config.validate()
        self.config = config
        self.state = "WAIT"
        self.fresh = 0

    def step(self, ages, reset=False):
        bad = []
        for name in ("localization", "controller"):
            age = ages.get(name)
            if type(age) not in (int, float):
                bad.append(name)
            elif not math.isfinite(age):
                bad.append(name)
            elif not 0.0 <= age <= self.config.timeout_sec:
                bad.append(name)

        if bad:
            self.fresh = 0
            self.state = "HOLD"
            reason = "timeout:" + ",".join(bad)
        else:
            self.fresh = min(
                self.fresh + 1, self.config.recovery_samples
            )
            ready = self.fresh == self.config.recovery_samples
            if self.state == "WAIT":
                if ready:
                    self.state = "RUN"
                reason = "ready" if ready else "warming"
            elif self.state == "HOLD":
                if ready and reset:
                    self.state = "RUN"
                    reason = "reset"
                else:
                    reason = "latched"
            else:
                reason = "ready"

        limit = self.config.max_speed_mps if self.state == "RUN" else 0.0
        return self.state, limit, reason


def run_demo():
    gate = Gate(Config())
    events = [
        (0.1, 0.1, False),
        (0.1, 0.1, False),
        (0.8, 0.1, False),
        (0.1, 0.1, False),
        (0.1, 0.1, False),
        (0.1, 0.1, True),
    ]
    for index, (localization, controller, reset) in enumerate(events):
        ages = {
            "localization": localization,
            "controller": controller,
        }
        state, limit, reason = gate.step(ages, reset)
        print(
            f"{index:02d} {state} fresh={gate.fresh} "
            f"limit={limit:.2f} reason={reason}"
        )


def run_ros(args):
    import rclpy
    from rclpy.node import Node

    rclpy.init(args=args)
    node = None
    try:
        node = Node("config_probe")
        defaults = Config()
        values = {}
        for name in ("max_speed_mps", "timeout_sec", "recovery_samples"):
            parameter = node.declare_parameter(name, getattr(defaults, name))
            values[name] = parameter.value
        config = Config(**values)
        config.validate()
        sim = node.get_parameter("use_sim_time").value
        node.get_logger().info(
            f"CONFIG_OK max_speed_mps={config.max_speed_mps:.2f} "
            f"timeout_sec={config.timeout_sec:.2f} "
            f"recovery_samples={config.recovery_samples} "
            f"use_sim_time={sim}"
        )
    finally:
        if node is not None:
            node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    args = sys.argv[1:]
    if "--ros" in args:
        args.remove("--ros")
        run_ros(args)
    elif args:
        raise SystemExit("usage: config_probe.py [--ros ROS_ARGS]")
    else:
        run_demo()

launch/system.launch.py

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    share = FindPackageShare("duri_bringup")
    return LaunchDescription([
        DeclareLaunchArgument("namespace", default_value="duri"),
        DeclareLaunchArgument("use_sim_time", default_value="false"),
        DeclareLaunchArgument(
            "site_config",
            default_value=PathJoinSubstitution([
                share, "config", "yard.yaml"
            ]),
        ),
        IncludeLaunchDescription(
            PythonLaunchDescriptionSource(
                PathJoinSubstitution([
                    share, "launch", "inspection.launch.py"
                ])
            ),
            launch_arguments={
                "namespace": LaunchConfiguration("namespace"),
                "use_sim_time": LaunchConfiguration("use_sim_time"),
                "site_config": LaunchConfiguration("site_config"),
            }.items(),
        ),
    ])

launch/inspection.launch.py

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue


def generate_launch_description():
    return LaunchDescription([
        DeclareLaunchArgument("namespace", default_value="duri"),
        DeclareLaunchArgument("use_sim_time", default_value="false"),
        DeclareLaunchArgument("site_config"),
        Node(
            package="duri_bringup",
            executable="config_probe",
            name="config_probe",
            namespace=LaunchConfiguration("namespace"),
            arguments=["--ros"],
            parameters=[
                LaunchConfiguration("site_config"),
                {
                    "use_sim_time": ParameterValue(
                        LaunchConfiguration("use_sim_time"),
                        value_type=bool,
                    ),
                },
            ],
            output="screen",
        ),
    ])

config/yard.yaml

/**:
  ros__parameters:
    max_speed_mps: 0.4
    timeout_sec: 0.5
    recovery_samples: 2

CMakeLists.txt

cmake_minimum_required(VERSION 3.8)
project(duri_bringup)

find_package(ament_cmake REQUIRED)

install(
  PROGRAMS scripts/config_probe.py
  DESTINATION lib/${PROJECT_NAME}
  RENAME config_probe
)

install(
  DIRECTORY launch config
  DESTINATION share/${PROJECT_NAME}
)

ament_package()

package.xml

<?xml version="1.0"?>
<package format="3">
  <name>duri_bringup</name>
  <version>0.1.0</version>
  <description>Duri integration configuration example.</description>
  <maintainer email="maintainer@example.com">Duri Maintainer</maintainer>
  <license>Apache-2.0</license>
  <buildtool_depend>ament_cmake</buildtool_depend>
  <exec_depend>rclpy</exec_depend>
  <exec_depend>launch</exec_depend>
  <exec_depend>launch_ros</exec_depend>
  <exec_depend>ros2launch</exec_depend>
  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

줄별 해설

Config는 세 설정을 한 묶음으로 다룬다. frozen=True는 생성한 설정을 실행 도중 대입으로 바꾸지 못하게 한다. 이 예제는 시작 시 설정을 확정하는 방식이다. 실행 중 파라미터 변경을 허용하려면 변경 요청 검사와 실제 적용 시점을 별도로 설계해야 한다.

validate()의 첫 반복문은 속도와 시간의 자료형을 검사한 뒤 유한한 양수인지 확인한다. Python의 논리형은 정수와 관계가 있으므로 여기서는 type(value)로 허용한 형식을 좁힌다. 복구 횟수는 실수 2.0도 받아들이지 않는다. 입력이 잘못되었을 때 조용히 보정하면 설정 오류를 놓칠 수 있으므로 예외를 발생시킨다.

Gate.__init__()은 검사를 통과한 설정만 저장한다. 최초 상태는 WAIT이고 연속 정상 횟수는 0이다. step()은 두 정보의 나이를 정해진 순서로 검사한다. 키가 없으면 get()이 반환한 값이 숫자 검사에서 걸리므로, 정보 누락도 이동 허용 조건을 만족하지 못한다.

bad가 비어 있지 않으면 횟수를 초기화하고 HOLD로 전환한다. 시작 직후에 잘못된 정보가 들어와도 같은 규칙을 적용한다. 출력의 timeout:은 이 모형에서 누락이나 잘못된 나이까지 묶는 간단한 분류다. 실제 진단에서는 누락, 시간 역행, 만료를 서로 다른 원인으로 기록하는 편이 좋다.

정상 관측에서는 min()으로 횟수를 필요한 값까지만 증가시킨다. WAIT에서는 횟수가 충족되면 이동을 허용한다. HOLD에서는 횟수 충족과 현재 호출의 reset이 함께 필요하다. 조건이 부족할 때 들어온 재출발 요청은 저장하지 않는다. 나중에 자동으로 효력이 생기지 않도록 하기 위한 선택이다.

limit은 RUN일 때만 설정 속도를 반환한다. 이것은 제어기에 적용할 수 있는 상한을 표현한 값이며 현재 속도나 정지 확인값이 아니다. 반환값을 로그에만 기록하면 실제 로봇의 행동에는 영향이 없다. 모형을 현장 프로그램으로 옮길 때 이 연결을 빠뜨리기 쉽다.

run_demo()의 여섯 입력은 준비, 만료, 수신 회복, 재출발을 차례로 보여 준다. 실제 시간을 기다리지 않으므로 실행할 때마다 같은 결과를 얻는다. run_ros() 안에서만 ROS 모듈을 가져오기 때문에 순수 Python 실행에서는 ROS 설치가 필요하지 않다.

ROS 노드는 파라미터를 선언할 때 YAML에서 전달된 값을 반영한다. use_sim_time은 rclpy 노드의 시간 지원에서 선언하는 값을 읽는다. 검사가 끝나면 로그를 남기고 finally에서 자원을 정리한다. 검사 실패 시 예외가 전파되어 프로세스가 실패로 종료된다. 상위 운영 프로그램이 이 결과를 실제 출발 조건으로 사용하려면 종료 상태를 확인하는 절차를 추가해야 한다.

상위 실행 파일의 FindPackageShare는 설치된 패키지의 공유 디렉터리를 찾는다. 하위 파일은 필수 인자인 site_config를 받아 노드에 전달한다. ParameterValue는 문자열 형태의 실행 인자를 논리형 파라미터로 변환한다. 마지막으로 CMake의 두 설치 항목이 실행 파일과 설정 자료를 각각 ROS가 찾는 위치에 둔다.

실행 결과

다음 명령은 duri_ws/src/duri_bringup에서 실행한다. 첫 명령은 경고를 오류로 취급하여 세 Python 파일을 컴파일한다. 성공하면 출력이 없다. 이어서 ROS 없이 보조 예제를 실행한다. 여기 제시한 결과는 코드에 따른 예상 출력이며, 이 원고에서 실제 실행을 수행한 기록은 아니다.

python3 -W error -m py_compile scripts/config_probe.py launch/system.launch.py launch/inspection.launch.py
python3 scripts/config_probe.py
00 WAIT fresh=1 limit=0.00 reason=warming
01 RUN fresh=2 limit=0.40 reason=ready
02 HOLD fresh=0 limit=0.00 reason=timeout:localization
03 HOLD fresh=1 limit=0.00 reason=latched
04 HOLD fresh=2 limit=0.00 reason=latched
05 RUN fresh=2 limit=0.40 reason=reset

04행에서는 정상 횟수가 충족되었지만 속도 상한은 0이다. 05행의 재출발 요청이 있어야 이동을 허용한다. 단순히 데이터가 다시 들어오는 순간 이전 이동을 이어 가지 않는다는 점이 핵심이다. 이 결과는 상태 전이를 확인하며 실제 제동 성능을 검증하지 않는다.

ROS 실행은 Jazzy 환경과 colcon을 준비한 터미널에서 진행한다. 다음 명령은 위 패키지 디렉터리에서 두 단계 위인 작업 공간으로 이동한다. 셸에 맞는 설치 환경 스크립트를 사용하며, 아래에서는 zsh와 bash에서 읽을 수 있는 local_setup.sh를 사용한다.

cd ../..
colcon build --packages-select duri_bringup
. install/local_setup.sh
ros2 launch duri_bringup system.launch.py namespace:=duri use_sim_time:=false

시각, 프로세스 번호, 빌드 진행 메시지는 환경에 따라 달라진다. 다음은 검사 노드가 기록하는 메시지 본문만 발췌한 예상값이다. 노드는 이 메시지를 남긴 뒤 정상 종료하므로, 이후 지속적으로 실행되는 감시 노드는 없다.

CONFIG_OK max_speed_mps=0.40 timeout_sec=0.50 recovery_samples=2 use_sim_time=False

설정 파일을 바꾸었다면 다시 빌드해 설치본을 갱신하거나 site_config에 사용할 파일의 절대 경로를 전달한다. 소스 파일만 수정한 뒤 설치본이 자동으로 바뀌었다고 가정하지 않는다. Python 컴파일 성공은 문법 확인이며, ROS 패키지 탐색과 실행 동작까지 확인한 결과는 아니다.

실무에서 자주 틀리는 것

문자열 false를 Python의 bool로 변환한다

비어 있지 않은 문자열을 bool()에 전달하면 내용이 false여도 참이 된다. 실행 인자는 실행 시점에 해석되는 치환 객체이므로 Python의 즉시 변환 대신 파라미터 자료형을 명시한다.

틀린 코드는 다음과 같다.

simulation = bool("false")
parameters = [{"use_sim_time": simulation}]

고친 코드는 다음과 같다.

simulation = ParameterValue(
    LaunchConfiguration("use_sim_time"),
    value_type=bool,
)
parameters = [{"use_sim_time": simulation}]

현재 디렉터리에서 설정 파일을 찾는다

상대 경로는 실행한 터미널 위치에 따라 다른 파일을 가리키거나 실패한다. 패키지 자료는 설치 위치를 기준으로 찾고, 운영자가 고르는 외부 파일은 명시적인 경로로 전달한다.

틀린 코드는 다음과 같다.

parameters = ["config/yard.yaml"]

고친 코드는 다음과 같다.

parameters = [
    PathJoinSubstitution([
        FindPackageShare("duri_bringup"),
        "config",
        "yard.yaml",
    ])
]

경로를 고쳐도 CMake에서 파일을 설치하지 않으면 실행 환경에서는 찾을 수 없다. 경로 계산과 자료 설치를 함께 확인한다.

정보가 회복되면 바로 이동을 재개한다

단일 정상 관측만으로 HOLD를 해제하면 통신이 흔들릴 때 이동 허용이 반복해서 바뀐다. 회복 확인과 재출발 요청을 모두 요구하고, 장애가 다시 관측되면 정상 횟수를 초기화한다.

틀린 코드는 다음과 같다.

if self.state == "HOLD" and not bad:
    self.state = "RUN"

고친 코드는 다음과 같다.

if self.state == "HOLD" and not bad:
    ready = self.fresh == self.config.recovery_samples
    if ready and reset:
        self.state = "RUN"

재출발 요청은 실제 시스템에서 신뢰할 수 있는 경로로 받아야 한다. 이 모형의 논리값 자체가 운영자 인증이나 주변 환경 확인을 수행하지는 않는다.

양수이면 모든 수치 설정이 유효하다고 본다

숫자 형식이 맞아도 무한대는 시간 한계를 무력화할 수 있다. 숫자가 아닌 부동소수점 값인 NaN은 일반적인 비교만으로 의도대로 걸러지지 않는다. 먼저 형식과 유한성을 확인한다.

틀린 코드는 다음과 같다.

if timeout_sec <= 0:
    raise ValueError("invalid timeout")

고친 코드는 다음과 같다.

if type(timeout_sec) not in (int, float):
    raise ValueError("number required")
if not math.isfinite(timeout_sec) or timeout_sec <= 0:
    raise ValueError("finite positive timeout required")

그다음에는 현장 정책에 맞는 상한을 검사한다. 형식이 유효한 값과 운행에 적합한 값은 서로 다른 검토 대상이다.

한눈에 보기

실행 구성에서 재출발까지 이어지는 통합 점검 기준
대상설계 기준점검할 질문
패키지변경 책임에 따라 분리장치를 바꾸면 어디를 수정하는가
실행 계층상위는 공통 선택, 하위는 기능 조립인자가 끝까지 같은 의미로 전달되는가
설정출처·단위·적용 순서를 명시실제 적용된 값을 재현할 수 있는가
준비 판단프로세스 시작과 기능 준비를 구분유효한 정보가 들어오는가
장애 대응이동 허용 해제와 실제 정지를 확인판단 프로그램이 멈추어도 정지하는가
복구정상 유지와 재출발 허가를 함께 요구이전 배달을 계속할 조건이 확인되었는가

통합 결과를 인계할 때는 실행 명령 하나와 함께 설정 파일, 적용값 기록, 장애 대응 점검표를 제공한다. 두리의 현재 상태를 설명할 수 있는 자료가 남아 있어야 다음 담당자가 문제를 재현하고 복구 여부를 판단할 수 있다.

API와 설정 문법을 더 확인할 때는 Jazzy launch_ros 실행 동작 문서와 Jazzy rclpy 노드 문서를 참고한다. 이 장의 코드와 장애 시나리오는 두리의 통합 구조를 설명하기 위해 구성한 예제다.

연습 문제

  1. recovery_samples를 3으로 바꾸고 같은 여섯 입력을 사용한다. 각 행의 상태와 속도 상한을 구한다.
  2. HOLD에서 첫 정상 관측에만 재출발 요청을 보내고 이후 요청을 보내지 않는다고 가정한다. 정상 횟수가 충족된 뒤의 상태를 설명하고, 이러한 규칙의 운영상 의미를 적는다.
  3. 현장 YAML에 use_sim_time: true를 추가하고 상위 실행에서는 use_sim_time:=false를 전달한다. 예제에서 적용되는 값과 중복 설정을 정리하는 방법을 설명한다.
  4. 제어기 프로세스를 재시작하는 동안 위치 정보는 계속 정상으로 들어온다. 정지 확인, 증거 수집, 재출발 판단을 포함한 점검 절차를 작성한다.

정답과 해설

  1. 상태는 차례로 WAIT, WAIT, HOLD, HOLD, HOLD, RUN이다. 속도 상한은 앞의 다섯 행에서 0.00이고 마지막 행에서 0.40이다. 첫 두 정상 관측으로는 준비 횟수 3을 채우지 못한다. 만료 관측이 횟수를 초기화하고, 마지막 세 정상 관측 중 세 번째에 재출발 요청도 있으므로 RUN으로 바뀐다.
  2. HOLD를 유지한다. 예제는 너무 일찍 들어온 요청을 저장하지 않는다. 복구 조건이 갖추어진 시점에 다시 요청해야 한다. 운영 화면에는 요청이 받아들여지지 않은 이유와 부족한 정상 횟수를 표시하는 편이 좋다. 그래야 담당자가 요청 누락과 조건 미충족을 구분할 수 있다.
  3. false가 적용된다. 하위 실행 파일의 파라미터 목록에서 YAML 뒤에 명시적 사전 설정을 두었기 때문이다. 다만 우선순위를 이용해 중복을 방치하기보다 YAML에서 시간 설정을 제거하고 상위 실행 인자를 소유 위치로 유지한다. 운영 기록에는 실행 인자와 최종 적용값을 함께 남긴다.
  4. 제어기 응답 만료를 감지하면 이동 허용을 해제하고 명령 경로와 구동기에서 정지를 확인한다. 마지막 명령·응답 시각, 종료 원인, 재시작 횟수, 설정 식별자를 기록한다. 재시작 뒤에는 새 응답이 정상적으로 유지되는지와 명령 경로가 복구되었는지 확인한다. 주변 상황과 남은 배달 목표를 검토한 뒤 재출발을 요청한다. 위치 정보가 정상이라는 이유만으로 제어기 복구를 대신 판단하지 않는다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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