12궤적 계획 — 부드럽게 움직이기
관절 공간 보간, 5차 다항식·사다리꼴 속도 프로파일, 직선 경로와 픽앤플레이스 시퀀스를 만듭니다.
왜 순간이동하면 안 될까요#
11장에서는 IK 로 구한 각도를 arm.move_joints() 로 바로 보냈습니다. 시뮬레이터에서는 문제가 없지만, 실물 로봇에게 "지금 0° 인 joint2 를 다음 순간 90° 로!" 라고 명령하면 모터는 온 힘을 다해 그 각도로 가려고 합니다.
- 큰 가속 → 큰 힘: 팔 전체 무게를 한순간에 휘둘러 모터와 기어에 충격이 갑니다.
- 흔들림 · 오버슈트: 세게 출발했다 급히 서면서 목표를 지나쳤다 돌아오며 떨립니다.
- 위험: 들고 있던 물건이 날아가고 주변 사람·물건과 부딪힐 수 있습니다.
숫자로 보면 더 분명합니다. 제어기가 1초에 100번(100 Hz) 명령을 보낸다면 명령 간격은 0.01초입니다.
import math
dt = 1 / 100 # 100 Hz 제어 주기
dq = math.radians(90) # joint2: 0° → 90°
v_jump = dq / dt # 한 주기 만에 가려면 필요한 속도
print(f"한 주기에 점프: {v_jump:.0f} rad/s = {math.degrees(v_jump):.0f} °/s")
print(f"2초에 나눠 가기: {dq / 2:.3f} rad/s = {90 / 2:.0f} °/s")한 주기에 점프: 157 rad/s = 9000 °/s
2초에 나눠 가기: 0.785 rad/s = 45 °/s그래서 로봇은 목표까지 가는 중간 자세들을 촘촘하게 만들어 차례로 보냅니다.
경로는 "어떤 길로 가는가"(지나가는 자세의 순서), 궤적은 경로에 시간을 붙인 것, 즉 "언제 어디에 있는가"입니다. 같은 경로라도 천천히 출발하면 다른 궤적입니다. 이 장의 앞부분은 "얼마나 빠르게"(시간 프로파일), 뒷부분은 "어떤 길로"(직선 경로)를 다룹니다.
선형 보간 — 중간 자세 만들기#
출발 자세 q0 에서 목표 q1 까지의 중간은 진행률 s(0 → 1)로 나타냅니다.
q(s) = q0 + (q1 − q0) · s s = 0 이면 출발, s = 1 이면 도착이것을 선형 보간(linear interpolation, lerp) 이라고 합니다. 관절 6개를 모두 같은 s 로 보간하면 모든 관절이 동시에 출발해 동시에 도착합니다.
def lerp_pose(q0, q1, s):
return [a + (b - a) * s for a, b in zip(q0, q1)]
q0 = [0, 45, 90, 0, 0, 0]
q1 = [40, 70, 110, -30, 20, 0]
for i in range(5):
s = i / 4
print(f"s = {s:.2f} →", [round(v, 1) for v in lerp_pose(q0, q1, s)])s = 0.00 → [0.0, 45.0, 90.0, 0.0, 0.0, 0.0]
s = 0.25 → [10.0, 51.2, 95.0, -7.5, 5.0, 0.0]
s = 0.50 → [20.0, 57.5, 100.0, -15.0, 10.0, 0.0]
s = 0.75 → [30.0, 63.8, 105.0, -22.5, 15.0, 0.0]
s = 1.00 → [40.0, 70.0, 110.0, -30.0, 20.0, 0.0]zip(q0, q1) 은 두 리스트에서 같은 칸끼리 짝지어 꺼내 줍니다. (DM 모델이면 joint2·3 부호를 반대로 넣으세요.)
문제는 s 를 시간에 정비례로 늘리면(s = t / T) 출발하는 순간 속도가 0 에서 갑자기 일정 속도로 튀고, 도착 순간 갑자기 0 이 된다는 것입니다. 속도가 끊기는 곳에서 가속도는 이론상 무한대입니다.
부드러운 출발과 정지 — smoothstep 과 5차 다항식#
해결책은 s 를 시간에 대한 곡선으로 만드는 것입니다. 처음과 끝에서 천천히, 가운데에서 빠르게.
| 프로파일 | s(τ), τ = t/T | 출발·도착 속도 | 출발·도착 가속도 | 최고 속도 / 평균 |
|---|---|---|---|---|
| 선형 | τ | 튐 | 무한대 | 1.0 |
| smoothstep (3차) | 3τ² − 2τ³ | 0 | 튐 | 1.5 |
| 5차 다항식 (최소 저크) | 10τ³ − 15τ⁴ + 6τ⁵ | 0 | 0 | 1.875 |
5차 다항식은 속도뿐 아니라 가속도까지 0 에서 출발해 0 으로 끝나서 가장 부드럽습니다. 가속도의 변화율을 저크(jerk) 라 하는데, 저크가 작을수록 덜컹거림이 적어 "최소 저크(minimum jerk)" 궤적이라고도 부릅니다.
def linear(t):
return t
def smoothstep(t):
return 3 * t**2 - 2 * t**3
def quintic(t):
return 10 * t**3 - 15 * t**4 + 6 * t**5
N = 10
for name, f in [("linear", linear), ("smoothstep", smoothstep), ("quintic", quintic)]:
speeds = [(f((i + 1) / N) - f(i / N)) * N for i in range(N)] # 구간별 속도
bars = "".join(" ▁▂▃▄▅▆▇█"[min(8, round(v / 1.875 * 8))] for v in speeds)
print(f"{name:>10} |{bars}| 최고 {max(speeds):.2f}") linear |▄▄▄▄▄▄▄▄▄▄| 최고 1.00
smoothstep |▁▃▅▆▆▆▆▅▃▁| 최고 1.48
quintic | ▂▄▇██▇▄▂ | 최고 1.83글자 막대그래프로 속도 모양을 그려 봤습니다. 선형은 처음부터 끝까지 같은 높이(시작·끝에서 튐), 나머지는 낮게 시작해 낮게 끝납니다. (10칸으로 나눠 잰 평균이라 최고값이 표의 1.5, 1.875 보다 살짝 작게 나옵니다.)
사다리꼴 속도 프로파일#
산업용 로봇과 모터 드라이버에서 가장 흔한 것은 사다리꼴(trapezoidal) 프로파일입니다. 속도 그래프가 사다리꼴 모양입니다.
- 가속 구간: 일정한 가속도로 속도를 올립니다.
- 등속 구간: 최고 속도 vmax 를 유지합니다.
- 감속 구간: 일정한 가속도로 줄여 0 에서 멈춥니다.
장점은 최고 속도와 최대 가속도를 직접 정할 수 있다는 것입니다. "이 관절은 초당 30° 이상, 초당 60°/s² 이상으로는 절대 움직이지 마"를 그대로 반영할 수 있습니다.
import math
def trapezoid_time(D, vmax, amax):
"""거리 D 를 vmax·amax 를 지키며 가는 최소 시간과 가속 시간"""
ta = vmax / amax # 최고 속도까지 걸리는 시간
if D < vmax * ta: # 너무 짧아 최고 속도에 못 닿음 → 삼각형
ta = math.sqrt(D / amax)
return 2 * ta, ta
return D / vmax + ta, ta
def trapezoid_s(t, T, ta):
"""시간 t 에서의 진행률 s (0~1)"""
vp = 1 / (T - ta) # 정규화된 최고 속도
if t < ta:
return 0.5 * vp / ta * t * t
if t < T - ta:
return 0.5 * vp * ta + vp * (t - ta)
r = T - t
return 1 - 0.5 * vp / ta * r * r
for D in [90, 10]: # 관절을 90°, 10° 움직이기
T, ta = trapezoid_time(D, vmax=30, amax=60)
shape = "사다리꼴" if T > 2 * ta + 1e-9 else "삼각형"
print(f"{D}° 이동: 총 {T:.2f} s, 가속 {ta:.2f} s ({shape})")
print(" s:", [round(trapezoid_s(T * i / 6, T, ta), 2) for i in range(7)])90° 이동: 총 3.50 s, 가속 0.50 s (사다리꼴)
s: [0.0, 0.11, 0.31, 0.5, 0.69, 0.89, 1.0]
10° 이동: 총 0.82 s, 가속 0.41 s (삼각형)
s: [0.0, 0.06, 0.22, 0.5, 0.78, 0.94, 1.0]짧은 이동(10°)은 최고 속도 30°/s 에 닿기 전에 감속해야 해서 속도 그래프가 삼각형이 됩니다. 이렇게 사다리꼴은 거리에 따라 모양이 자동으로 바뀝니다.
- 정해진 시간에 부드럽게: 5차 다항식 (사람 손 동작과 비슷해 시연·데이터 수집에 좋음)
- 속도·가속도 한계를 정확히 지키며 가장 빨리: 사다리꼴
- 계산이 간단하고 웬만큼 부드럽게: smoothstep
제어 주기와 이동 시간 계산#
제어 주기 — 점을 몇 개 보낼까#
궤적은 결국 일정한 시간 간격 dt 마다 보낼 자세 목록입니다. 이 간격을 제어 주기, 1/dt 를 제어 주파수(Hz) 라고 합니다. 실물 제어기는 보통 수십 ~ 수백 Hz 로 명령을 보냅니다(공식 SDK 의 실제 주기는 Python SDK 저장소와 위키를 확인하세요). Playground 에서는 dt = 0.05초(20 Hz) 정도면 충분히 부드럽게 보입니다.
def quintic(t):
return 10 * t**3 - 15 * t**4 + 6 * t**5
def plan(q0, q1, T, dt=0.05, profile=quintic):
"""q0 → q1 을 T 초 동안, dt 간격 자세 리스트로"""
n = max(1, round(T / dt))
traj = []
for i in range(n + 1):
s = profile(i / n)
traj.append([a + (b - a) * s for a, b in zip(q0, q1)])
return traj
arm.home()
q0 = arm.get_joints()
q1 = arm.ik(0.40, 0.10, 0.30) # 목표를 IK 로 구하기 (위치만)
if q1 is None:
print("IK 실패 — 목표를 바꾸세요")
else:
dt = 0.05
traj = plan(q0, q1, T=2.0, dt=dt)
print("궤적 점 개수:", len(traj))
vmax = 0
for a, b in zip(traj, traj[1:]): # 이웃한 두 점 사이의 관절 속도
v = max(abs(y - x) for x, y in zip(a, b)) / dt
vmax = max(vmax, v)
print(f"가장 빠른 관절 속도: {vmax:.1f} °/s")
for q in traj[1:]:
arm.move_joints(q, t=dt) # 짧은 명령을 차례로 쌓기시뮬레이터에서 팔이 천천히 출발해 천천히 멈추는지 보세요. 출력된 "가장 빠른 관절 속도"가 생각보다 크다면 T 를 늘려야 합니다. 얼마나 늘려야 하는지는 바로 다음에 계산합니다. profile=quintic 을 lambda t: t(선형)로 바꿔 비교하면 출발·정지 순간의 차이가 보입니다.
속도 한계로 이동 시간 정하기#
지금까지 T 를 "2초"처럼 사람이 정했습니다. 거꾸로 "가장 빠른 순간에도 vmax 를 넘지 않으려면 최소 몇 초?" 를 계산할 수 있습니다. 최고 속도 = k · |Δq| / T 이므로(k 는 위 표의 배수) 다음과 같습니다.
T = k · max|Δq| / vmax (선형 k = 1, smoothstep k = 1.5, 5차 k = 1.875)관절이 여러 개면 가장 많이 움직이는 관절에 맞춥니다. 모두 같은 s 로 함께 움직이니까요.
K = {"linear": 1.0, "smoothstep": 1.5, "quintic": 1.875}
def auto_duration(q0, q1, vmax=30.0, profile="quintic"):
"""모든 관절이 vmax(°/s)를 넘지 않는 가장 짧은 시간(초)"""
worst = max(abs(b - a) for a, b in zip(q0, q1))
return max(0.3, K[profile] * worst / vmax)
q0 = [0, 45, 90, 0, 0, 0]
q1 = [60, 80, 120, -30, 20, 0]
for p in K:
print(f"{p:>10}: {auto_duration(q0, q1, profile=p):.2f} s") linear: 2.00 s
smoothstep: 3.00 s
quintic: 3.75 s공식 URDF 의 <limit velocity="…"> (RS 의 joint1~3 은 50) 는 모델 파일에 적힌 상한일 뿐, 그대로 쓰라는 값이 아닙니다. 처음 실물을 움직일 때는 이 장의 예처럼 초당 수십 도 이하의 느린 속도로 시작하고, 시뮬레이터에서 궤적을 확인한 뒤 조금씩 올리세요. 토크가 켜진 팔의 작업 범위 안에 손을 넣지 말고, 비상정지 수단을 손 닿는 곳에 두는 것도 잊지 마세요(8장).
관절 공간 경로 vs 직교 공간 직선#
두 점 A, B 사이를 움직이는 방법은 두 가지입니다.
| 관절 공간 (joint space) | 직교 공간 (Cartesian space) | |
|---|---|---|
| 무엇을 보간? | 관절 각도 q | 손끝 위치 p |
| IK 횟수 | 처음·끝 2번 | 점마다 |
| 손끝 길 | 곡선 (예측 어려움) | 직선 |
| 장점 | 빠르고 특이점에 안전 | 길이 예측 가능 (집기·삽입·그리기) |
| Playground | move_joints, move_to | move_line |
관절 공간 이동에서 손끝이 얼마나 휘는지 직접 재 봅시다.
import math
A = (0.30, -0.10, 0.10)
B = (0.25, 0.15, 0.10)
qA = arm.ik(*A, down=True)
qB = arm.ik(*B, down=True)
def dist_to_line(p, a, b):
"""점 p 에서 선분 a-b 까지의 거리"""
ab = [b[i] - a[i] for i in range(3)]
ap = [p[i] - a[i] for i in range(3)]
t = sum(ab[i] * ap[i] for i in range(3)) / sum(v * v for v in ab)
t = max(0, min(1, t))
return math.dist(p, [a[i] + ab[i] * t for i in range(3)])
if qA and qB:
worst = 0
for i in range(11):
s = i / 10
q = [a + (b - a) * s for a, b in zip(qA, qB)] # 관절 공간 보간
p = arm.fk(q)
worst = max(worst, dist_to_line(p, A, B))
print(f"관절 공간 이동: 손끝이 직선에서 최대 {worst * 1000:.1f} mm 벗어남")
arm.move_joints(qA, t=2)
arm.move_joints(qB, t=2) # 관절 공간 (곡선)
arm.move_joints(qA, t=2)
arm.move_line(*B, t=2, steps=20) # 직교 공간 (직선)시뮬레이터의 TCP 축 보기를 켜고 두 번의 이동을 비교해 보세요. 첫 번째는 손끝이 바깥으로 휘어 지나가고(URDF 로 계산해 보면 약 3 cm), 두 번째는 A→B 를 곧게 갑니다. 높이(z)가 같은 두 점인데도 관절 공간 이동에서는 손끝 높이가 출렁일 수도 있습니다. 바닥 가까이에서 움직일 때 이것이 충돌의 원인이 됩니다.
직선 위의 모든 점에서 IK 가 풀려야 합니다. 직선이 작업 영역 밖이나 특이점 근처를 지나면 중간에 실패합니다. 큰 이동은 관절 공간으로, 물체 근처의 짧은 이동만 직선으로 하는 것이 실전 요령입니다.
픽앤플레이스 시퀀스 — 큐브 옮기기#
"물체를 집어 다른 곳에 놓기(pick and place)"는 로봇팔의 가장 대표적인 일입니다. 한 번에 짜려면 어렵지만, 거쳐 갈 점 웨이포인트로 쪼개면 하나하나는 지금까지 배운 이동입니다.
| 단계 | 하는 일 | 이동 방식 | 이유 |
|---|---|---|---|
| 1 접근 | 큐브 바로 위로 | 관절 공간 (move_to) | 빈 공중이라 휘어도 됨 |
| 2 하강 | 큐브 높이로 내려가기 | 직선 (move_line) | 옆으로 휘면 큐브를 침 |
| 3 잡기 | 그리퍼 닫기 + 잠깐 대기 | 팔은 정지 | 움직이며 쥐면 미끄러짐 |
| 4 상승 | 다시 위로 | 직선 | 바닥·주변을 긁지 않게 |
| 5 이동 | 놓을 곳 위로 | 관절 공간 | 큰 이동은 빠르고 안전하게 |
| 6 하강 · 7 놓기 · 8 상승 | 내려가 열고 올라오기 | 직선 / 정지 / 직선 | 2~4 와 같은 이유 |
이 강좌의 TCP(그리퍼 몸체 원점)는 손가락 끝에 있어서, 그리퍼를 아래로 향하면 TCP 높이가 곧 손가락 끝 높이입니다. 손가락이 실제로 물체를 쥐는 곳은 끝에서 약 2 cm 안쪽입니다. 시뮬레이터의 큐브 잡기 규칙은 단순합니다.
- 그리퍼 벌림이 22 mm 미만이고, 쥐는 점(TCP 에서 2 cm 안쪽)이 큐브 중심에서 3.5 cm 이내이면 큐브가 붙습니다.
- 그리퍼를 22 mm 이상 열면 놓습니다. 공중에서 놓으면 바닥(z = 0.02)으로 떨어집니다.
그래서 권장 높이는 다음과 같습니다. 기본 큐브는 (0.40, 0, 0.02), 상자(bin)는 중심 (0.25, 0.32), 높이 0.045 m 에 있습니다.
| 단계 | TCP 목표 | 메모 |
|---|---|---|
| 접근 | 큐브 위, z = 큐브 z + 0.07 | 아래 방향 자세는 반경 0.4 m 에서 z 약 0.10 m 까지만 가능(11장) |
| 하강 | z = 큐브 z + 0.005 (기본 큐브면 0.025) | 쥐는 점이 큐브 중심 높이에 옴 |
| 상승 · 이동 | z = 0.10 | 상자 벽(0.045)보다 충분히 높게 |
| 놓기 | 상자 위 z = 0.07 | 상자 안에서 그리퍼 열기 |
move_line 은 시작 자세의 접근 방향을 유지한 채 직선으로 움직이므로, 위에서 down=True 로 접근해 두면 하강·상승 동안 그리퍼가 계속 아래를 향합니다.
- 시뮬레이터의 보기 옵션에서 큐브를 켜세요.
- 아래 코드를 실행하세요. 큐브를 집어 상자에 넣고 홈으로 돌아옵니다.
cube = arm.cube()
if cube is None:
print("보기 옵션에서 큐브를 켜 주세요.")
else:
cx, cy, cz = cube
BIN = (0.25, 0.32) # 상자 중심 (x, y)
APPROACH = cz + 0.07 # 접근 높이
GRASP = cz + 0.005 # 잡는 높이 (TCP = 손가락 끝)
CARRY = 0.10 # 들고 다닐 높이
DROP = 0.07 # 상자 위에서 놓는 높이
print(f"큐브 ({cx:.3f}, {cy:.3f}, {cz:.3f}) → 상자 {BIN}")
def go(x, y, z, t=2.0):
if not arm.move_to(x, y, z, t=t, down=True):
raise RuntimeError(f"IK 실패: ({x:.3f}, {y:.3f}, {z:.3f})")
arm.home()
arm.open()
go(cx, cy, APPROACH) # 1 접근
arm.move_line(cx, cy, GRASP, t=1.5, steps=15) # 2 하강
arm.close() # 3 잡기
arm.wait(0.5)
arm.move_line(cx, cy, CARRY, t=1.5, steps=15) # 4 상승
go(BIN[0], BIN[1], CARRY) # 5 상자 위로 이동
arm.move_line(BIN[0], BIN[1], DROP, t=1.5, steps=15) # 6 하강
arm.open() # 7 놓기
arm.wait(0.5)
arm.move_line(BIN[0], BIN[1], CARRY, t=1.5, steps=15) # 8 상승
arm.home()
print("명령 예약 완료 — 재생을 지켜보세요.")- 재생이 끝나면
print(arm.cube())만 따로 한 번 더 실행해 큐브가 상자 근처 (0.25, 0.32) 로 옮겨졌는지 확인하세요. (Playground 는 코드가 끝난 뒤에 동작을 재생하므로, 같은 코드 안에서arm.cube()를 다시 불러도 아직 옮기기 전 위치가 나옵니다.) GRASP를cz + 0.05로 바꿔 다시 해 보세요. 쥐는 점이 큐브에서 3.5 cm 넘게 떨어져 잡히지 않습니다. 또DROP을 0.14 그대로 두고 놓으면 큐브가 상자 위 공중에서 떨어집니다.- (도전)
APPROACH를cz + 0.25로 올려 보세요. 높이 0.20 m 를 넘으면RuntimeError: IK 실패가 납니다. 그리퍼를 아래로 향한 자세가 닿지 않는 높이이기 때문입니다(11장 작업 영역).
위 코드는 IK 가 실패하면 즉시 멈추도록 raise 를 썼습니다. 실물에서 중간 단계 하나가 실패했는데 다음 명령을 계속 보내면, 큐브를 쥔 채 엉뚱한 곳으로 가거나 바닥을 긁을 수 있습니다. 실물에서는 각 단계 전에 목표가 권장 작업영역(70%) 안인지, 접근 높이가 책상·주변 물체보다 충분히 높은지 먼저 확인하세요. 처음 실행은 반드시 시뮬레이터에서, 그다음 실물에서는 느린 속도와 비상정지 준비 상태로 하세요.
정리#
- 경로 = 지나가는 자세의 순서, 궤적 = 경로 + 시간. 순간이동 대신 촘촘한 중간 자세를 보냅니다.
- 선형 보간
q0 + (q1 − q0)·s로 중간 자세를 만들고, s 를 시간 곡선으로 만들면 부드러워집니다. - smoothstep(3τ² − 2τ³)은 속도가, 5차 다항식(10τ³ − 15τ⁴ + 6τ⁵)은 가속도까지 0 에서 시작·끝납니다. 최고 속도는 평균의 1.5배, 1.875배.
- 사다리꼴 프로파일은 vmax·amax 를 직접 지키며, 짧은 이동에서는 삼각형이 됩니다.
- 이동 시간은
T = k · max|Δq| / vmax로 가장 많이 움직이는 관절에 맞춰 정합니다. - 관절 공간 이동은 손끝이 휘고, 직교 공간 직선(
move_line)은 점마다 IK 를 풀어 곧게 갑니다. - 픽앤플레이스 = 접근 → 직선 하강 → 잡기 → 직선 상승 → 이동 → 하강 → 놓기 → 상승. 단계마다 실패를 검사합니다.
확인 문제#
- 경로와 궤적의 차이는 무엇인가요?
- 같은 시간 T 동안 같은 거리를 갈 때, 5차 다항식 프로파일의 최고 속도는 선형의 몇 배인가요?
- joint1 을 60°, joint2 를 30° 움직이는 이동을 smoothstep 으로, 최고 속도 30°/s 를 넘지 않게 하려면 최소 몇 초가 필요할까요?
- 큐브 바로 위에서 큐브 높이로 내려갈 때 관절 공간 대신 직선 이동을 쓰는 이유는?
- Playground 에서 픽앤플레이스 코드 마지막에
print(arm.cube())를 넣으면 옮기기 전 위치가 나오는 이유는?
- 경로는 지나가는 자세(점)의 순서, 궤적은 그 경로에 "언제 어디에 있는가" 라는 시간 정보를 붙인 것입니다.
- 1.875배. (선형은 1배, smoothstep 은 1.5배)
- T = 1.5 × 60 / 30 = 3.0초. 가장 많이 움직이는 joint1(60°)에 맞춥니다.
- 관절 공간으로 내려가면 손끝이 곡선으로 움직여 손가락이 큐브를 옆에서 칠 수 있기 때문입니다. 직선이면 수직으로 곧게 내려갑니다.
- Playground 는 코드를 끝까지 실행한 뒤에 쌓인 동작을 재생하므로, 코드 실행 중에는 큐브가 아직 옮겨지지 않았기 때문입니다.