Devin.KR

측정 시각과 처리 시각

80분 안팎

학습 목표

자세 이력을 보간해 측정 시각의 변환을 적용하고 지연 오류를 비교합니다.

개념

같은 점에 다른 시각의 자세를 더하면 틀어집니다

로봇이 0.2m/s로 전진하는 동안 센서 메시지가 0.15초 늦게 도착했다고 가정합니다. 측정 당시의 거리 점에 수신 당시의 로봇 위치를 더하면 정지한 장애물이 0.03m 앞으로 이동한 것처럼 표시됩니다. 좌표 식이 정확해도 입력 자세의 시각이 틀리면 결과가 달라집니다. 이 레슨은 자세 이력의 사이 값을 구하고 측정 시각으로 투영하며 지원하지 않는 시각을 실패로 드러냅니다.

세 시각의 역할을 구분합니다

stamp_s는 측정이 일어난 가상 시각, received_s는 입력이 도착한 가상 시각, now는 현재 처리 시각입니다. 동일한 시계를 사용하며 stamp_s ≤ received_s ≤ now를 검사합니다. 전달 지연은 received_s-stamp_s이고 입력의 현재 나이는 now-stamp_s입니다. 수신 뒤 대기열에 오래 머물렀다면 전달 지연이 짧아도 나이는 클 수 있습니다. 따라서 신선도 검사에서 수신 시각만 뺄 수는 없습니다.

stamp를 received로 덮어쓰면 로그는 새 데이터처럼 보여도 측정이 이루어진 자세는 복원할 수 없습니다. 세 값은 원래 의미를 유지해 기록합니다. 실제 장치에서 서로 다른 시계를 쓴다면 두 값을 그대로 빼는 식부터 성립하지 않을 수 있습니다. 이번 실습은 가상 시계를 공유하는 계약 아래 실행하고 운영체제 시간이나 장치 시계 보정까지 구현했다고 주장하지 않습니다.

이력은 순서를 보존하며 검사합니다

History의 입력은 [(시각,(x,y,yaw)),...]입니다. 빈 이력, 중복 시각, 역순 시각, 유한하지 않은 자세는 거절합니다. 생성자에서 정렬해 순서를 숨기지 않습니다. 온라인 생산자가 과거 데이터를 보냈다는 문제를 그대로 드러내야 하기 때문입니다. 정확히 첫 시각이나 마지막 시각을 요청하면 그 자세를 그대로 반환하므로 단 하나의 기록이라도 그 시각은 지원합니다.

두 시각 ta와 tb 사이에서는 ratio=(stamp-ta)/(tb-ta)를 계산합니다. x와 y는 a+ratio*(b-a)로 보간합니다. stamp가 중간이면 ratio=0.5이며 두 위치의 평균입니다. starter는 ratio를 0으로 둬 이전 자세만 반환하므로 실제 보간 테스트가 실패합니다. 이 한 줄을 완성하면 고정 속도 fixture의 0.03m 지연 오차를 없앨 수 있고 기존 입력 검사는 그대로 유지됩니다.

yaw는 단순 뺄셈 대신 atan2(sin(b-a),cos(b-a))로 짧은 각도 차이를 구합니다. +179도에서 -179도로 이동하면 실제 변화는 경계를 넘는 약 +2도이며 -358도로 보간하면 중간 자세가 반대 방향을 향합니다. 정확히 반 바퀴 차이는 회전 방향에 추가 정보가 필요한 모호한 사례입니다. 이번 계약은 두 표본 사이 작은 방향 변화를 가정하며 실제 빠른 회전의 다중 회전을 복원하지 않습니다.

범위 안의 보간과 범위 밖의 예측을 구별합니다

stamp가 첫 기록보다 과거이거나 마지막 기록보다 미래이면 OUTSIDE_HISTORY입니다. 마지막 자세로 clamp하거나 원점을 반환하지 않습니다. 이력 밖 데이터가 정상 좌표로 바뀌면 경로와 충돌 검사에서 조용한 오차를 만들 수 있습니다. 실제 시스템은 제한된 시간 동안 변환 도착을 기다리는 정책을 둘 수 있지만 이번 미션은 즉시 거절하고 정지하는 보수적인 계약을 사용합니다.

보간은 두 표본 사이 움직임을 가정하는 계산이며 그 안의 실제 자세를 항상 정확히 아는 것은 아닙니다. fixture는 일정 속도 직선이라 선형 보간이 정확합니다. 급가속이나 곡선 주행에서 기록 간격이 크면 시각을 맞춰도 보간 오차가 남습니다. 보간 오차와 엔코더 잡음을 따로 평가하려면 더 촘촘한 참값 기록을 만들어 비교해야 하며 이번 예시의 1e-6 기준은 고정 fixture에 한정합니다.

실패 시 모터 출력까지 확인합니다

RangeGate는 거리 0.05~4.0m, 엄격히 증가하는 stamp, 이력 범위, 입력 나이를 검사합니다. 0.20초는 허용하지만 이를 넘으면 STALE입니다. 이 작은 경계에서 이진 실수 반올림을 처리하기 위해 1e-12초 여유만 사용합니다. 0.201초를 허용하는 느슨한 오차 기준이 아닙니다. 유효한 거리라도 0.20m 이하 근접은 NEAR로 거절하고 주행 액션을 끝냅니다.

receive는 승인된 관측을 저장하고 같은 측정 시각의 estimated_pose를 사용해 map 점을 만듭니다. 입력 거절이면 SENSOR_이유를 가진 ABORTED와 PWM (0,0)을 함께 기록합니다. 다음 tick에서도 관측이 없거나 오래되었는지 확인합니다. 한 번 승인된 데이터가 영원히 안전하지 않기 때문입니다. 정지 상태 문자열만 검사하지 않고 실제 HAL 출력과 자세가 움직이지 않았는지도 테스트합니다.

순서 검사는 마지막 승인 stamp보다 작거나 같은 새 stamp를 거절합니다. 중복 표본을 새 데이터처럼 세면 발행이 끊겨도 값이 갱신되는 것처럼 보일 수 있습니다. 이번 정책은 입력 스트림이 한 개이며 실패 후 기존 액션은 재개하지 않습니다. 새 액션 제출 전에는 유효한 관측을 다시 넣어야 합니다. 재전송을 허용하는 통신에서는 별도 번호와 중복 정책을 정의해야 합니다.

앞 프로젝트에 추정 경계를 연결합니다

미션은 m07 solution을 모두 포함합니다. EncoderHal.pose는 바퀴 이동량 적분 결과를 반환하고 물리 HAL 자세는 truth_pose로 평가 기록에만 남깁니다. NavigationActions는 추정 자세로 경유점 제어와 경로 구간 확인을 수행합니다. 추정 자세에서 안전하다고 보인 구간과 실제 참값 구간은 다를 수 있으므로 실험에서는 참값 기반 실제 충돌 수도 따로 봅니다. 무잡음 열린 지도에서는 두 값이 거의 일치합니다.

거리 점을 map에 투영하는 기능은 시간 계약 확인이며 위치를 자동으로 보정하는 센서 융합은 아닙니다. 앞 레슨의 거리 점을 지도에 연결하려면 데이터 대응과 관측 모델이 더 필요합니다. 미션의 잡음 조건은 일정한 직진 입력으로 엔코더 추정을 평가하며 장애물 주행의 성공을 검증하지 않습니다. 센서 보정이 없는 추정 경로를 실물에서 안전하다고 결론내리지 않고 추가 시험 범위를 적습니다.

sensor_experiments는 동시각 position_rmse_m과 지연 위치 해석의 naive_rmse_m, aligned_rmse_m을 나눠 출력합니다. 지연은 기록을 해석할 때 쓰이며 실제 바퀴를 더 흔들지 않습니다. 따라서 같은 seed와 같은 잡음이면 동시각 위치 RMSE는 지연 조건에서도 같습니다. 보정 후 aligned 오차가 남는다면 엔코더 잡음 또는 보간 모델 오차를 의심합니다. 시각 보정이 모든 센서 오차를 제거한다고 말하지 않습니다.

고정 seed의 잡음과 지연이 우연히 반대 방향이면 naive 오차가 aligned보다 작게 나올 수도 있습니다. 이는 틀린 시각이 더 정확한 방식이라는 근거가 아닙니다. 무잡음 고정 속도 fixture로 시간 선택의 정확성을 먼저 검증하고 잡음 조건의 결과는 별도로 해석합니다. 여러 seed의 집계 없이 한 번의 수치 차이로 필터나 시간 보정의 성능 순위를 정하지 않습니다.

검사 흐름과 제출 증거를 남깁니다

bash check.sh는 직접 이전 check-m07.sh를 실행한 뒤 C 공유 라이브러리를 임시 폴더에 만들고 새 테스트를 실행합니다. 테스트가 check.sh를 다시 부르지 않아 재귀가 없습니다. 셸이 종료되면 임시 라이브러리를 지웁니다. 이전 회귀가 실패하면 새 수치를 보기 전에 환경과 원래 모듈의 테스트를 확인합니다. 신규 실패는 History 보간·입력 시각·정지 출력 순서로 원인을 좁힙니다.

완료 증거에는 무잡음·0.02m 잡음·0.15초 지연 조건의 JSON, 순서 오류와 누락 시 정지, 0.20초 경계, 고정 속도 0.03m 비교를 포함합니다. seed·dt·표준편차의 의미도 같이 적어 다른 사람이 같은 명령으로 재현하도록 합니다. 더 읽기는 실제 tf2 버퍼와 서로 다른 두 시각 조회로 확장합니다. 이 레슨에서는 해당 API를 흉내낸다고 주장하지 않고 순수 Python의 시간 선택 계약을 직접 검증합니다.

따라하기

측정 시각의 자세로 점을 배치합니다

독립적으로 실행하는 PC 계산입니다. 동일한 세계의 정지점 1.20m와 비교합니다.

import math

def finite(value):
    return type(value) in (int, float) and math.isfinite(value)

class History:
    def __init__(self, samples):
        if not samples: raise ValueError('EMPTY_HISTORY')
        self.samples = list(samples)
        for t, p in self.samples:
            if not finite(t) or len(p) != 3 or not all(finite(v) for v in p):
                raise ValueError('BAD_POSE')
        if any(a[0] >= b[0] for a,b in zip(self.samples,self.samples[1:])):
            raise ValueError('ORDER')
    def at(self, stamp):
        if not finite(stamp): raise ValueError('BAD_STAMP')
        if not self.samples[0][0] <= stamp <= self.samples[-1][0]:
            raise ValueError('OUTSIDE_HISTORY')
        for t,p in self.samples:
            if t == stamp: return tuple(p)
        for (ta,a),(tb,b) in zip(self.samples,self.samples[1:]):
            if ta < stamp < tb:
                ratio = (stamp-ta)/(tb-ta)
                angle = math.atan2(math.sin(b[2]-a[2]), math.cos(b[2]-a[2]))
                return (a[0]+ratio*(b[0]-a[0]), a[1]+ratio*(b[1]-a[1]),
                        math.atan2(math.sin(a[2]+ratio*angle),math.cos(a[2]+ratio*angle)))
        raise ValueError('OUTSIDE_HISTORY')

def project(history, stamp, point, mount=(0.10,0.0)):
    if len(point)!=2 or not all(finite(v) for v in point): raise ValueError('BAD_POINT')
    x,y,yaw = history.at(stamp)
    px,py = point[0]+mount[0],point[1]+mount[1]
    return (x+math.cos(yaw)*px-math.sin(yaw)*py,
            y+math.sin(yaw)*px+math.cos(yaw)*py)

class RangeGate:
    def __init__(self, max_age=0.20):
        if not finite(max_age) or max_age<0: raise ValueError('BAD_AGE')
        self.max_age=max_age;self.last_stamp=None
    def accept(self, sample, now, history):
        if not isinstance(sample,dict): raise ValueError('MISSING')
        if not finite(now): raise ValueError('BAD_NOW')
        stamp=sample.get('stamp_s');received=sample.get('received_s');r=sample.get('range_m')
        if not all(finite(v) for v in (stamp,received,r)) or not 0.05 <= r <= 4.0:
            raise ValueError('BAD_RANGE')
        if not stamp <= received <= now: raise ValueError('CLOCK')
        if self.last_stamp is not None and stamp <= self.last_stamp: raise ValueError('ORDER')
        if now-stamp > self.max_age+1e-12: raise ValueError('STALE')
        point=project(history,stamp,(r,0.0))
        if r <= 0.20: raise ValueError('NEAR')
        self.last_stamp=stamp
        return point

h=History([(0,(0,0,0)),(1,(.2,0,0))])
for name,t in [('naive',.65),('aligned',.50)]:
    p=project(h,t,(1,0))
    print(f'{name}: map_x={p[0]:.6f} error_m={abs(p[0]-1.20):.6f}')

실행 결과

naive: map_x=1.230000 error_m=0.030000
aligned: map_x=1.200000 error_m=0.000000

각도 경계와 지원 범위를 검사합니다

중간 yaw는 약 180도입니다. 범위 밖 요청을 최신값으로 바꾸지 않습니다.

import math

def finite(value):
    return type(value) in (int, float) and math.isfinite(value)

class History:
    def __init__(self, samples):
        if not samples: raise ValueError('EMPTY_HISTORY')
        self.samples = list(samples)
        for t, p in self.samples:
            if not finite(t) or len(p) != 3 or not all(finite(v) for v in p):
                raise ValueError('BAD_POSE')
        if any(a[0] >= b[0] for a,b in zip(self.samples,self.samples[1:])):
            raise ValueError('ORDER')
    def at(self, stamp):
        if not finite(stamp): raise ValueError('BAD_STAMP')
        if not self.samples[0][0] <= stamp <= self.samples[-1][0]:
            raise ValueError('OUTSIDE_HISTORY')
        for t,p in self.samples:
            if t == stamp: return tuple(p)
        for (ta,a),(tb,b) in zip(self.samples,self.samples[1:]):
            if ta < stamp < tb:
                ratio = (stamp-ta)/(tb-ta)
                angle = math.atan2(math.sin(b[2]-a[2]), math.cos(b[2]-a[2]))
                return (a[0]+ratio*(b[0]-a[0]), a[1]+ratio*(b[1]-a[1]),
                        math.atan2(math.sin(a[2]+ratio*angle),math.cos(a[2]+ratio*angle)))
        raise ValueError('OUTSIDE_HISTORY')

def project(history, stamp, point, mount=(0.10,0.0)):
    if len(point)!=2 or not all(finite(v) for v in point): raise ValueError('BAD_POINT')
    x,y,yaw = history.at(stamp)
    px,py = point[0]+mount[0],point[1]+mount[1]
    return (x+math.cos(yaw)*px-math.sin(yaw)*py,
            y+math.sin(yaw)*px+math.cos(yaw)*py)

class RangeGate:
    def __init__(self, max_age=0.20):
        if not finite(max_age) or max_age<0: raise ValueError('BAD_AGE')
        self.max_age=max_age;self.last_stamp=None
    def accept(self, sample, now, history):
        if not isinstance(sample,dict): raise ValueError('MISSING')
        if not finite(now): raise ValueError('BAD_NOW')
        stamp=sample.get('stamp_s');received=sample.get('received_s');r=sample.get('range_m')
        if not all(finite(v) for v in (stamp,received,r)) or not 0.05 <= r <= 4.0:
            raise ValueError('BAD_RANGE')
        if not stamp <= received <= now: raise ValueError('CLOCK')
        if self.last_stamp is not None and stamp <= self.last_stamp: raise ValueError('ORDER')
        if now-stamp > self.max_age+1e-12: raise ValueError('STALE')
        point=project(history,stamp,(r,0.0))
        if r <= 0.20: raise ValueError('NEAR')
        self.last_stamp=stamp
        return point

h=History([(0,(0,0,math.radians(179))),(1,(0,0,math.radians(-179)))])
print(f'middle_deg={math.degrees(h.at(.5)[2]):.1f}')
try: h.at(1.1)
except ValueError as e: print(e)

실행 결과

middle_deg=180.0
OUTSIDE_HISTORY

현재 나이로 경계를 검사합니다

0.20초 경계와 초과·누락을 각각 확인합니다. 실제 모터 정지는 로컬 미션에서 검사합니다.

import math

def finite(value):
    return type(value) in (int, float) and math.isfinite(value)

class History:
    def __init__(self, samples):
        if not samples: raise ValueError('EMPTY_HISTORY')
        self.samples = list(samples)
        for t, p in self.samples:
            if not finite(t) or len(p) != 3 or not all(finite(v) for v in p):
                raise ValueError('BAD_POSE')
        if any(a[0] >= b[0] for a,b in zip(self.samples,self.samples[1:])):
            raise ValueError('ORDER')
    def at(self, stamp):
        if not finite(stamp): raise ValueError('BAD_STAMP')
        if not self.samples[0][0] <= stamp <= self.samples[-1][0]:
            raise ValueError('OUTSIDE_HISTORY')
        for t,p in self.samples:
            if t == stamp: return tuple(p)
        for (ta,a),(tb,b) in zip(self.samples,self.samples[1:]):
            if ta < stamp < tb:
                ratio = (stamp-ta)/(tb-ta)
                angle = math.atan2(math.sin(b[2]-a[2]), math.cos(b[2]-a[2]))
                return (a[0]+ratio*(b[0]-a[0]), a[1]+ratio*(b[1]-a[1]),
                        math.atan2(math.sin(a[2]+ratio*angle),math.cos(a[2]+ratio*angle)))
        raise ValueError('OUTSIDE_HISTORY')

def project(history, stamp, point, mount=(0.10,0.0)):
    if len(point)!=2 or not all(finite(v) for v in point): raise ValueError('BAD_POINT')
    x,y,yaw = history.at(stamp)
    px,py = point[0]+mount[0],point[1]+mount[1]
    return (x+math.cos(yaw)*px-math.sin(yaw)*py,
            y+math.sin(yaw)*px+math.cos(yaw)*py)

class RangeGate:
    def __init__(self, max_age=0.20):
        if not finite(max_age) or max_age<0: raise ValueError('BAD_AGE')
        self.max_age=max_age;self.last_stamp=None
    def accept(self, sample, now, history):
        if not isinstance(sample,dict): raise ValueError('MISSING')
        if not finite(now): raise ValueError('BAD_NOW')
        stamp=sample.get('stamp_s');received=sample.get('received_s');r=sample.get('range_m')
        if not all(finite(v) for v in (stamp,received,r)) or not 0.05 <= r <= 4.0:
            raise ValueError('BAD_RANGE')
        if not stamp <= received <= now: raise ValueError('CLOCK')
        if self.last_stamp is not None and stamp <= self.last_stamp: raise ValueError('ORDER')
        if now-stamp > self.max_age+1e-12: raise ValueError('STALE')
        point=project(history,stamp,(r,0.0))
        if r <= 0.20: raise ValueError('NEAR')
        self.last_stamp=stamp
        return point

h=History([(0,(0,0,0)),(1,(.2,0,0))])
s=dict(stamp_s=.5,received_s=.65,range_m=1)
for sample,now in [(s,.70),(s,.701),(None,.65)]:
    try:
        RangeGate().accept(sample,now,h)
        print('ACCEPTED')
    except ValueError as e: print(e)

실행 결과

ACCEPTED
STALE
MISSING

확인 문제

실습

starter.zip을 별도 폴더에 풀고 sensor_time.py의 History.at에서 ratio TODO를 완성합니다. bash check.sh 한 줄로 Python 테스트 12개와 비교 demo를 실행합니다. 보간·끝점·각도 경계·장착 위치·이력 밖·0.20초 경계·누락·역순·수신 후 대기를 검사합니다. starter는 실제 assertion 실패, solution은 전체 통과입니다. 미션 zip에는 앞 모듈 코드와 C HAL 회귀가 함께 들어 있으며 gcc가 필요합니다. 미션에서는 같은 보간 구현을 경로 액션의 시간 검사와 엔코더 추정 평가에 연결하고 세 조건 JSON과 정지 근거를 제출합니다.

시작 코드·테스트 내려받기

실행 명령

bash check.sh

기대 결과

12개 테스트 통과; naive_error_m=0.030000, aligned_error_m=0.000000

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 센서 잡음을 추가한 시뮬레이션을 어떻게 평가하는지 설명해 주시면 됩니다.
  • 실험 기록을 재생할 때 함께 남겨야 할 정보를 설명해 주시면 됩니다.