토픽 이름·타입과 발행 주기
160분 안팎
학습 목표
주어진 메시지 열에서 토픽·필드 형식 불일치를 판정합니다.
개념
숫자가 도착해도 계약은 틀릴 수 있습니다
센서 화면에 1250이라는 값이 도착했습니다. 생산자는 mm를 보냈고 소비자는 m로 읽었다면 통신 자체는 성공했어도 상태는 잘못되었습니다. 이름과 타입, 필드와 단위, 발행 주기를 함께 검사하는 이유입니다. 이번 레슨에서는 JSONL 메시지 열을 읽고 계약을 위반한 지점을 판정합니다. 오류를 정상 숫자로 강제 변환해 감추기보다 최초 불일치 이유를 짧은 코드로 출력하여 수정할 위치를 찾습니다.
브라우저 과제는 이 레슨만의 간소화된 봉투 형식을 사용합니다. 한 줄은 topic, type, data 세 필드의 JSON 객체입니다. data에는 seq, stamp_s, frame_id, range_m, sample_valid, status가 있습니다. 미션에서 추가하는 pub_seq와 pub_s는 이 과제에서 검사하지 않습니다. 각 행은 독립적으로 검증하며 순서와 주기 분석은 별도 따라하기에서 수행합니다. 봉투가 잘못된 JSON이면 JSON, 봉투 구조가 다르면 ENVELOPE를 출력합니다.
이름과 타입은 연결의 첫 계약입니다
기대 이름은 /robot/range_status입니다. /robot/rnage_status처럼 문자 순서가 바뀌면 NAME을 출력합니다. 이 과제는 절대 이름의 정확한 문자열 일치만 검사하므로 네임스페이스나 리매핑을 구현하지 않습니다. 실제 ROS에서는 상대 이름이 실행 문맥에 따라 해석될 수 있어 소스 문자열뿐 아니라 최종 해석된 이름도 확인해야 합니다. 같은 단어가 들어 있다는 이유로 같은 토픽이라 판정하지 않습니다.
기대 타입 라벨은 bootcamp/RangeStatusV1입니다. 이름이 맞아도 type이 String이면 TYPE입니다. 라벨이 일치하는 것만으로 필드가 올바른지는 알 수 없어 그 뒤 data 검사를 진행합니다. 이 라벨은 학습용이며 실제 .msg 정의나 생성된 Python 클래스가 아닙니다. 실제 메시지 패키지 생성 과정은 더 읽기로 보냅니다. 여기서는 문자열 타입 식별과 필드 검사 책임을 구별하는 데 집중합니다.
범위를 검사하기 전에 형식을 검사합니다
seq는 0 이상 정수입니다. Python에서 bool은 int의 하위 형식이므로 isinstance(True, int)는 참입니다. 그러나 측정 번호 true는 이 계약의 정수가 아닙니다. type(value) is int로 검사해 불리언을 제외합니다. stamp_s와 range_m는 int 또는 float를 허용하되 불리언과 비유한 값을 제외합니다. 문자열 "1.0"을 자동 변환하지 않습니다. 생산자의 직렬화 오류가 수신 경계에서 드러나도록 합니다.
필드가 빠진 경우와 값이 잘못된 경우는 FIELD라는 범주로 판정합니다. range_m는 0.05 이상 8 이하이고 stamp_s는 0 이상입니다. 최소값과 최대값은 유효하며 0.049와 8.001은 벗어납니다. math.isfinite는 NaN과 Infinity를 제외합니다. JSON 표준에서 이 값은 일반 숫자가 아니지만 Python 파서가 비표준 상수를 받아들일 수 있어 수치 경계에서도 방어합니다. 숫자 비교만으로 NaN을 정상값으로 처리하지 않도록 주의합니다.
필드 간 의미도 일치해야 합니다
frame_id는 이번 프로젝트에서 sensor로 고정합니다. map이라고 적으면 숫자의 기준이 달라져 FIELD입니다. 상태 코드는 정수 0, 1, 2, 3 중 하나이며 sample_valid는 실제 bool입니다. 코드 0일 때만 sample_valid=true여야 합니다. 2와 true가 함께 오면 실패 상태를 정상 관측처럼 쓰게 되므로 FIELD입니다. 타입이 맞는 각 필드가 서로 모순될 수 있다는 점을 테스트로 확인합니다.
상태가 2이고 sample_valid=false인 행도 형태상 유효하므로 OK를 출력합니다. 여기서 OK는 현재 관측을 제어에 쓸 수 있다는 뜻이 아닙니다. 토픽 메시지 계약에 맞게 결함을 표현했다는 뜻입니다. 화면은 이 행을 INVALID로 표시합니다. 실패를 전달할 메시지 자체를 버리면 정상 수신과 장치 고장 정보를 구분할 수 없습니다. 오류 상태와 통신 형식 오류를 분리하는 이유입니다.
주기는 시간 차이로 확인합니다
0.05초 간격이면 초당 20번의 발행 일정입니다. 연속 stamp_s 또는 pub_s의 차이를 어떤 의미로 계산하는지 먼저 정해야 합니다. 독립 측정을 20Hz로 생성하는 예제에서는 stamp_s 간격을 볼 수 있지만 미션은 측정 세 행을 재발행하므로 pub_s를 분석합니다. 마지막 발행 시각만 보고 20Hz였다고 결론 내리지 않고 번호 개수와 연속 간격을 함께 확인합니다.
구간 [0,1)은 0초를 포함하고 1초를 제외합니다. tick을 0부터 19까지 만들고 tick*0.05를 계산하면 발행 시각은 0부터 0.95초까지입니다. 반복 덧셈으로 t를 늘리며 t가 1보다 작은지 비교하면 부동소수 누적 오차로 경계가 흔들릴 수 있습니다. 정수 tick을 사용하면 일정 개수를 명시할 수 있습니다. 간격 비교는 소수의 정확한 일치 대신 작은 허용 오차를 적용합니다.
판정 순서를 고정하면 재현이 쉬워집니다
과제의 우선순위는 JSON, ENVELOPE, NAME, TYPE, FIELD, OK입니다. 이름과 타입이 모두 틀린 행은 NAME 하나만 출력합니다. 전부 틀린 지점을 한 번에 보고하는 도구도 가능하지만 이번 과제는 첫 원인을 고정하여 입력과 기대 출력이 재현되도록 합니다. 이름을 고친 뒤 TYPE이 나타나는 것은 새로운 오류가 생긴 것이 아니라 다음 검사 경계에 도달한 결과일 수 있습니다.
빈 표준 입력은 아무 줄도 출력하지 않습니다. 여러 JSON 행이 오면 각 행의 판정 한 줄을 같은 순서로 출력합니다. 내부 빈 줄은 JSON 파싱 오류로 처리합니다. 안내 문구를 stdout에 추가하면 채점 출력이 달라집니다. 디버깅이 필요하면 로컬 실행에서는 stderr를 사용하고 최종 제출에서는 요구한 판정 문자열만 남깁니다. JSON 파싱 실패와 키 검사 실패를 넓은 예외 하나로 숨기지 않습니다.
경계 사례를 먼저 준비합니다
정상 행, 이름 오타, 타입 불일치, 최소·최대 거리, 결함 정상 표현, 불리언 번호, 문자열 거리, 누락 필드, 깨진 JSON을 테스트합니다. 정상 하나만 통과하는 코드는 실제 수신 장애를 진단하기 어렵습니다. 특히 상태 2와 false가 OK인지, 상태 0과 false가 FIELD인지 비교하면 값 하나의 검사와 필드 관계 검사의 차이가 드러납니다. 각 결과에 어떤 검사가 걸렸는지 설명해 보세요.
FIELD를 봤다면 data를 순서대로 조사합니다. 필수 키가 있는지, seq가 불리언인지, 수치가 문자열이나 비유한 값인지, 단위 범위와 frame_id가 맞는지, 상태와 유효성이 일치하는지 확인합니다. 기대 범위를 실제 mm 값에 맞춰 크게 늘리는 수정은 단위 오류를 감춥니다. 생산자 경계에서 m로 변환하고 계약은 유지하는 편이 의미를 보존합니다. 이 판단을 코드와 테스트 두 곳에서 일관되게 설명합니다.
사실 확인 자료: ROS 2 Jazzy 공식 문서 원본 — 토픽과 타입·필드 의미의 공식 설명에서 해당 기준을 확인할 수 있습니다.
따라하기
불리언 번호를 구분합니다
Python의 정수 계층과 이 계약의 허용 형식이 다릅니다. 그대로 실행해 차이를 확인합니다.
print(isinstance(True, int))
print(type(True) is int)
print(type(0) is int)실행 결과
True False True
거리 경계를 비교합니다
단위는 m이며 두 끝값을 포함합니다. 각 입력에 대한 판정을 읽습니다.
import math
for value in (0.05, 8.0, 0.049, float("nan")):
ok = math.isfinite(value) and 0.05 <= value <= 8.0
print(f"{value}: {'OK' if ok else 'FIELD'}")실행 결과
0.05: OK 8.0: OK 0.049: FIELD nan: FIELD
가상 발행 일정을 만듭니다
정수 tick으로 반열린 구간을 구성합니다. 이 예제는 발행 일정이며 실제 수신 주파수 측정은 아닙니다.
times = [tick * 0.05 for tick in range(20)]
print(f"count={len(times)} first={times[0]:.3f} last={times[-1]:.3f}")
print(f"period={times[1]-times[0]:.3f} rate={1/(times[1]-times[0]):.1f}")실행 결과
count=20 first=0.000 last=0.950 period=0.050 rate=20.0
봉투 판정기를 작성합니다
아래 실습에서 classify를 구현합니다. JSON→봉투→이름→타입→필드 순서로 첫 원인을 반환합니다. 정상 결함 표현도 OK가 되는지 확인하고 여러 줄과 빈 입력을 시험합니다.
확인 문제
실습
표준 입력의 각 JSON 행을 판정합니다. 봉투는 topic·type·data를 포함하며 이름 /robot/range_status, 타입 bootcamp/RangeStatusV1입니다. data의 seq는 0 이상 정수(bool 제외), stamp_s는 0 이상 유한 숫자, range_m는 [0.05,8] 유한 숫자(bool 제외), frame_id는 sensor입니다. status는 정수 0~3이며 sample_valid는 bool이고 status=0일 때만 true입니다. 추가 필드는 허용합니다. JSON→ENVELOPE→NAME→TYPE→FIELD 순서로 첫 오류를 출력하고 모두 맞으면 OK입니다. 빈 입력은 출력하지 않습니다.
모범 답안
import sys, json, math
def classify(raw):
try:
env=json.loads(raw)
except (json.JSONDecodeError, ValueError):
return "JSON"
if not isinstance(env,dict) or any(k not in env for k in ("topic","type","data")):
return "ENVELOPE"
if env["topic"] != "/robot/range_status": return "NAME"
if env["type"] != "bootcamp/RangeStatusV1": return "TYPE"
m=env["data"]
if not isinstance(m,dict) or any(k not in m for k in ("seq","stamp_s","frame_id","range_m","sample_valid","status")):
return "FIELD"
if type(m["seq"]) is not int or m["seq"] < 0: return "FIELD"
for k in ("stamp_s","range_m"):
if type(m[k]) not in (int,float) or not math.isfinite(m[k]): return "FIELD"
if m["stamp_s"] < 0 or not 0.05 <= m["range_m"] <= 8: return "FIELD"
if m["frame_id"] != "sensor" or type(m["sample_valid"]) is not bool: return "FIELD"
if type(m["status"]) is not int or m["status"] not in (0,1,2,3): return "FIELD"
if m["sample_valid"] != (m["status"] == 0): return "FIELD"
return "OK"
for line in sys.stdin:
print(classify(line))
더 읽기
면접 질문
- 센서 토픽에 데이터가 보이지 않을 때 확인할 순서를 설명해 주시면 됩니다.