가상 시계로 기록 재생
100분 안팎
학습 목표
기록 시각 순서로 센서와 명령을 재생하는 실행기를 구현합니다.
개념
빠르게 재생해도 로봇의 시간은 같습니다
기록 재생에서 실제 시계의 sleep으로 센서 간격을 흉내 내면 컴퓨터 부하가 실험 조건에 섞입니다. 늦게 실행된 콜백 때문에 입력 나이가 달라지고 같은 파일의 결과가 바뀔 수 있습니다. 가상 시계는 기록의 숫자를 현재 시각으로 삼습니다. 실제로 몇 초 만에 파일을 읽었는지는 로봇 상태 계산에 사용하지 않습니다. 이번 레슨은 실행 속도와 모델의 시간 진행을 분리하는 작업입니다.
재생기의 순서는 이벤트 정렬, 시각 이동, 핸들러 호출입니다. at_us가 100000인 줄을 처리하면 now_us도 100000입니다. 이 값은 이전 이벤트 시각보다 작아지지 않아야 합니다. seq는 같은 시각의 순서를 정하며 로그의 원래 행 위치를 대신하는 명시적인 계약입니다. Python 정렬이 안정적이라는 사실에만 의존하면 줄이 섞인 파일의 인과 관계를 복구하지 못합니다.
수신 순서와 측정 순서를 구별합니다
지연 센서는 측정 시각 50000us의 데이터를 수신 시각 200000us에 전달할 수 있습니다. 재생기는 200000us에 데이터를 전달하고 기존 History는 50000us의 자세를 찾아 점을 투영합니다. 측정 시각으로 이벤트를 정렬하면 실제로 늦게 도착한 정보가 과거 제어에 사용됩니다. 미래 정보를 미리 본 제어기는 현실보다 좋은 결과를 낼 수 있어 지연 평가가 잘못됩니다.
같은 시각의 goal·sensor·tick에도 순서가 있습니다. 시각 0에서 목표를 등록한 뒤 센서를 받아 tick을 실행하면 관측을 가지고 움직입니다. tick이 먼저면 센서 누락으로 정지할 수 있습니다. 그래서 같은 시각에서도 seq 순서를 입력의 일부로 보관합니다. 우선순위를 코드에 숨겨 kind 알파벳 순으로 정렬하지 않습니다. 작성자가 지정한 인과 순서가 코드 변경 후에도 유지되어야 합니다.
가상 시각은 정수 us로 저장해 파일에서의 순서 판정을 명확하게 합니다. 0.05초는 50000us입니다. 제어기에 전달할 때는 1000000으로 나눠 초로 바꾸며 바퀴 적분의 dt도 기존 설정과 일치시키고 확인합니다. 정수 저장이 모든 부동소수 계산 오차를 제거하는 것은 아닙니다. 위치·방향 적분은 기존 실수 계산을 유지하고 출력 직전에만 표현 규칙을 적용합니다.
상태 갱신 책임을 한곳에 둡니다
가상 시계를 구현하면서 각 이벤트마다 무조건 hal.step을 호출하는 실수가 흔합니다. sensor 두 줄이 같은 시각에 오면 로봇이 두 번 움직이는 오류가 생깁니다. 이 프로젝트에서는 tick만 상태를 진행합니다. sensor는 관측을 전달하고 goal은 액션을 등록합니다. tick 이벤트의 at_us는 nav.tick_index 곱하기 dt_us와 같아야 하며 다르면 TICK_GAP으로 거절합니다.
미션의 tick 시각은 한 제어 구간의 시작 시각입니다. at_us=0에서 받은 센서를 사용해 첫 0.05초 구간을 진행합니다. tick 상태 행의 at_us는 입력 구간 시작을 가리키며 저장된 자세는 그 구간 처리 후 상태입니다. pose_us는 상태의 물리 시각을 별도로 기록합니다. 결과를 그래프로 그릴 때 이 계약을 확인해야 입력 시각과 구간 종료 자세를 잘못 짝짓지 않습니다. 별도 물리 시각이 필요하면 tick_index와 dt를 이용해 계산합니다.
이전 모듈의 SensorNavigation은 센서 수신 검사와 tick 시점의 나이 검사를 모두 수행합니다. 수신할 때 신선했던 데이터도 다음 입력이 오지 않으면 오래될 수 있습니다. 따라서 receive가 통과했다는 사실만으로 모든 후속 tick을 허용하지 않습니다. 재생기는 tick을 통해 이 정지 조건을 실제로 실행하며 단순히 로그 줄을 출력하는 데 그치지 않습니다.
실행할 때마다 초기화합니다
같은 파일을 두 번 실행할 때는 새 HAL과 EncoderHal과 Navigation 객체를 만듭니다. Random 객체의 상태, 액션 목록, 자세 이력, 마지막 센서 순번이 이전 실행에서 남으면 두 번째 결과가 달라집니다. C HAL은 한 라이브러리 안에서 단일 상태를 쓰므로 이 미션의 사례는 순차 실행합니다. 객체 두 개를 만든 것만으로 C의 전역 상태까지 독립되었다고 가정하지 않습니다.
seed를 재설정해도 입력 난수 호출 횟수가 달라지면 표본이 바뀔 수 있습니다. 재생 중 센서와 tick을 같은 함수에서 처리하며 임의로 난수를 추가하지 않습니다. 미션은 EncoderHal의 좌우 바퀴 잡음 생성 순서를 유지합니다. 버전이 다른 실행 환경까지 동일 결과를 보장하려면 원시 잡음이나 엔코더 입력도 파일로 저장하는 확장이 필요합니다. 지금은 기록된 이벤트와 동일 Python 환경의 회귀 재현을 검증합니다.
재생 중 액션이 종료되면 입력 파일의 남은 줄은 실행하지 않습니다. 종료된 작업에 새 센서나 tick을 더 적용하면 결과 로그의 길이가 실행기 정책에 따라 바뀌기 때문입니다. 파일이 먼저 끝나고 액션이 진행 중이면 LOG_ENDED로 중단하고 PWM을 0으로 만듭니다. 파일 끝을 성공처럼 처리하거나 마지막 출력이 계속 유지되게 두지 않습니다.
테스트로 시계의 계약을 드러냅니다
로컬 starter의 ordered 함수는 검증은 하지만 입력 순서를 그대로 반환합니다. 역순 시각과 같은 시각의 뒤섞인 seq 검사만 실패하고 음수 시각·중복 순번·알 수 없는 kind 거절은 통과합니다. TODO를 (at_us, seq) 키 정렬로 바꾸면 실행 순서가 맞아집니다. 빈 목록의 마지막 가상 시각은 0으로 정하고 콜백이 호출되지 않는 경계도 검사합니다.
AssertionError에서 [2, 0, 1]과 [0, 1, 2]가 다르면 정렬 키와 반환 리스트를 먼저 봅니다. TIME은 시각 값의 형식 또는 범위, SEQ는 순번 형식이나 중복, KIND는 지원하지 않는 이벤트입니다. TICK_GAP은 단순 정렬 오류보다 tick 누락이나 dt 불일치를 뜻할 수 있습니다. 오류별로 입력 계약을 읽으면 실제 시계 대기 시간을 늘리는 잘못된 수정으로 빠지지 않습니다.
따라하기의 첫 코드는 서로 뒤섞인 세 이벤트를 정렬해 같은 시각에서 센서가 tick보다 앞서는지 보여 줍니다. 두 번째는 센서 나이를 us 단위로 계산합니다. 세 번째는 두 번 재생한 문자열에서 같은 해시가 나오는지 확인합니다. 작은 예제의 일치가 전체 C HAL 주행을 증명하는 것은 아니므로 미션에서 상태·결과 파일 전체의 해시 비교를 추가합니다.
완료 후에는 어떤 값이 수신 시각이고 어떤 값이 측정 시각인지 말로 설명할 수 있어야 합니다. 재생 속도를 바꿔도 dt가 변하지 않는 이유와 같은 시각 순서가 결과를 바꾸는 사례도 제출합니다. 실제 ROS 기록 도구와 그래프 확인은 더 읽기에 연결합니다. 여기서는 PC 모델의 시간 계약을 완성하고 실제 ROS 시계 연동을 검증했다고 확대해서 말하지 않습니다.
따라하기
수신 시각과 순번으로 재생합니다
같은 시각에도 sensor 다음 tick 순서를 유지하는지 확인합니다.
"""가상 수신 시각과 seq로 결정적인 입력 순서를 만듭니다."""
import math
def ordered(events):
if not isinstance(events,list): raise ValueError('EVENTS')
seen=set()
for e in events:
if not isinstance(e,dict) or type(e.get('at_us')) is not int or e['at_us']<0:
raise ValueError('TIME')
if type(e.get('seq')) is not int or e['seq']<0 or e['seq'] in seen:
raise ValueError('SEQ')
if e.get('kind') not in ('goal','sensor','tick'): raise ValueError('KIND')
seen.add(e['seq'])
return sorted(events,key=lambda e:(e['at_us'],e['seq']))
def replay(events, callback):
now_us=0
for event in ordered(events):
now_us=event['at_us']
callback(now_us,event)
return now_us
events=[dict(at_us=100000,seq=2,kind='tick'),dict(at_us=0,seq=0,kind='goal'),dict(at_us=100000,seq=1,kind='sensor')]
replay(events,lambda t,e:print(t,e['kind']))
실행 결과
0 goal 100000 sensor 100000 tick
측정 시각과 현재 나이를 나눕니다
수신 시각은 0.20초, 측정 시각은 0.05초인 입력입니다. 오래됨은 두 시각의 차이로 판정합니다.
stamp_us=50000
now_us=200000
print(f'age_s={(now_us-stamp_us)/1000000:.2f}')
print(f'transform_stamp_s={stamp_us/1000000:.2f}')
print('accepted=',now_us-stamp_us<=200000)
실행 결과
age_s=0.15 transform_stamp_s=0.05 accepted= True
같은 입력의 재생 문자열을 비교합니다
상태가 없는 작은 콜백부터 결정성을 확인합니다. 미션에서는 이 검사를 HAL 상태·결과 파일 전체로 확장합니다.
"""가상 수신 시각과 seq로 결정적인 입력 순서를 만듭니다."""
import math
def ordered(events):
if not isinstance(events,list): raise ValueError('EVENTS')
seen=set()
for e in events:
if not isinstance(e,dict) or type(e.get('at_us')) is not int or e['at_us']<0:
raise ValueError('TIME')
if type(e.get('seq')) is not int or e['seq']<0 or e['seq'] in seen:
raise ValueError('SEQ')
if e.get('kind') not in ('goal','sensor','tick'): raise ValueError('KIND')
seen.add(e['seq'])
return sorted(events,key=lambda e:(e['at_us'],e['seq']))
def replay(events, callback):
now_us=0
for event in ordered(events):
now_us=event['at_us']
callback(now_us,event)
return now_us
import hashlib
events=[dict(at_us=50000,seq=1,kind='tick'),dict(at_us=0,seq=0,kind='goal')]
def capture():
rows=[]
replay(events,lambda t,e:rows.append(f"{t}:{e['kind']}\n"))
return ''.join(rows).encode()
a,b=capture(),capture()
print(a.decode(),end='')
print('hash_equal=',hashlib.sha256(a).hexdigest()==hashlib.sha256(b).hexdigest())
실행 결과
0:goal 50000:tick hash_equal= True
확인 문제
실습
starter.zip을 풀고 replay_clock.py의 ordered 정렬 TODO를 완성합니다. (at_us, seq) 순으로 정렬하되 원본을 바꾸지 않습니다. bash check.sh로 7개 unittest와 demo를 실행합니다. 역순 파일·동시각 순번·빈 입력·콜백·음수/비정수 시각·중복 seq·알 수 없는 kind를 검사합니다. starter는 정렬 assertion 일부가 실패하고 solution은 모두 통과합니다. 이 zip은 시계 기능만 분리한 실습이며 미션 zip은 완성한 시계를 앞 모듈 주행 코드에 연결합니다.
실행 명령
bash check.sh
기대 결과
7개 테스트 통과; demo는 0 goal, 100000 sensor, 100000 tick 순서
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 실험 기록을 재생할 때 함께 남겨야 할 정보를 설명해 주시면 됩니다.