29개 숫자는 왜 29개 관절이 아닌가: G1 joint order·motor index·checkpoint 호환성

2026년 7월 6일, G1의 v303 checkpoint를 router에 연결하는 과정에서 이상한 조합을 발견했습니다. policy는 RSL-RL MLP로 로드됐고 observation과 action 차원도 각각 73 / 29로 맞았습니다. 응답 상태는 ok였습니다. 그런데 당시 router의 G1 관절 목록은 실제 G1Walk-v0 runtime 순서가 아니라 예전에 가정한 알파벳 순서였습니다.

길이 검사는 모두 통과했습니다. 의미는 거의 맞지 않았습니다. 수정 전 router 목록과 runtime 목록을 같은 index끼리 대조하니 이름이 일치한 곳은 29개 중 index 6 하나뿐이었습니다. 더구나 수정 전 목록에는 left_elbow_pitch_jointleft_elbow_roll_joint가 있었지만 runtime에는 left_elbow_jointleft_wrist_roll_joint가 있었습니다. 오른쪽도 같았습니다. 단순 permutation, 즉 같은 원소의 순서 변경만이 아니라 joint identity 집합부터 네 항목이 달랐습니다.

수정 전 profile에서 index 0은 왼쪽 발목 pitch라는 이름을 가졌지만 G1Walk runtime의 index 0은 왼쪽 hip pitch였습니다. 이름에 따른 permutation 없이 배열을 경계 너머로 넘기면 같은 숫자 하나가 두 관절 의미로 해석될 수 있었습니다. 관절 수, tensor shape, 값의 유한성, 심지어 각 index의 숫자가 limit 안에 있는지까지 검사해도 이 오류는 잡히지 않습니다. v303에서 고친 것은 policy 성능이 아니라 29개 위치가 같은 29개 관절을 뜻하도록 만드는 인터페이스 계약이었습니다.

v303에서 73D·29D shape 검사를 통과했지만 old router와 Isaac runtime joint index가 29개 중 1개만 일치했던 문제, 현재 runtime order와 Unitree motor index의 permutation, 필요한 compatibility manifest를 보여주는 다이어그램
29차원이라는 사실은 29개 위치의 의미를 보장하지 않습니다. v303 수정 전 router와 Isaac runtime은 같은 index의 이름이 1개만 같았고, 현재 Isaac runtime과 Unitree hardware motor order도 같은 index는 2개뿐입니다. hardware 연결에는 이름으로 만든 명시적 permutation과 역변환이 필요합니다. 그림을 누르면 원본 크기로 볼 수 있습니다.

shape 검사는 ‘몇 개’를 확인할 뿐 ‘무엇’을 확인하지 않습니다

신경망은 joint name을 보지 않습니다. 첫 번째 출력 neuron이 left_hip_pitch_joint인지 left_ankle_pitch_joint인지는 모델 구조에 들어 있지 않습니다. actor의 마지막 layer가 29개 값을 내보내면 checkpoint loader가 확인할 수 있는 것은 output width가 29라는 사실입니다.

현재 PolicyLoader도 checkpoint weight의 첫 layer에서 observation dimension을, 마지막 layer에서 action dimension을 추론합니다. inference 때 입력이 73개인지, 출력이 29개인지, NaN이나 Inf가 없는지는 검사합니다. 그러나 checkpoint 안의 17번째 action이 어느 joint인지 확인할 ordered name list는 읽지 않습니다. PPO checkpoint state 글에서 optimizer와 normalizer가 이어학습 상태의 일부라고 구분했듯이, 배포 호환성에서는 observation layout과 action joint order도 모델 상태의 일부로 보아야 합니다.

shape contract
  observation.shape == (73,)
  action.shape      == (29,)

semantic contract
  observation[15 + i] == q[joint_names[i]] - nominal[joint_names[i]]
  observation[44 + i] == dq[joint_names[i]]
  action[i]            == offset for joint_names[i]

위 두 계약은 다릅니다. 첫 번째가 통과해도 두 번째가 틀릴 수 있습니다. 오히려 크기가 정확히 맞는 잘못된 배열이 더 위험합니다. 예외가 나지 않고 끝까지 실행되기 때문입니다.

v303에서는 29개 중 한 index만 같은 이름을 가리켰습니다

수정 전 router profile은 “Isaac Lab alphabetical order”라는 주석 아래 왼쪽 ankle, elbow, hip, shoulder 순으로 29개 이름을 적어 두었습니다. 실제 official G1 USD를 초기화한 뒤 debug_g1_motion_trace.py --print_joints로 읽은 runtime 순서는 달랐습니다. 앞부분은 왼쪽 hip pitch, 오른쪽 hip pitch, waist yaw, 양쪽 hip roll, waist roll처럼 몸통 트리를 따라 interleave되어 있었습니다.

index 수정 전 router 해석 G1Walk runtime 해석 잘못 연결될 수 있던 의미
0 left ankle pitch left hip pitch 발목 offset을 hip 목표로 해석
1 left ankle roll right hip pitch 왼쪽 발목 값을 오른쪽 hip에 연결
2 left elbow pitch waist yaw 팔꿈치 값을 허리 yaw에 연결
6 left hip yaw left hip yaw 29개 중 같은 index에서 이름이 일치한 항목
9 left shoulder roll left knee 어깨 값을 무릎에 연결
28 waist yaw right wrist yaw 허리 값을 오른손목에 연결

이 표는 실제 실물 로봇이 저 명령으로 움직였다는 기록이 아닙니다. 당시 HumanoidAdapter.apply_to_sim()은 q target을 articulation에 쓰지 않고 앞의 여섯 값을 출력하는 미구현 경로였고, v303 전후의 동일 조건 closed-loop 대조도 없습니다. 따라서 과거의 어떤 낙상을 joint-order 오류 하나로 설명하면 안 됩니다. 확인된 사실은 더 좁습니다. 수정 전 경로는 73/29 shape가 맞아도 q·dq와 q target을 다른 joint 의미로 해석할 수 있었습니다.

현재 G1Walk의 정본은 파일에 적은 알파벳 순서가 아니라 live articulation 순서입니다

현재 G1WalkEnv는 별도 hard-coded nominal vector를 만들지 않고 robot.data.default_joint_pos를 복사합니다. joint position과 velocity도 robot.data.joint_pos, robot.data.joint_vel에서 runtime order 그대로 읽습니다. 허리와 팔처럼 선택적으로 다루는 group은 self.robot.joint_names에서 이름을 찾아 index를 만듭니다.

Isaac Lab Articulation APIjoint_names를 simulation view가 parsing한 순서의 ordered list로 정의합니다. Isaac Sim Articulation Controller 문서는 joint index를 따로 주지 않을 때 command 배열이 로봇의 전체 joint 수와 순서에 맞아야 한다고 명시합니다. source URDF에 이름이 존재한다는 사실만으로 runtime tensor index가 결정되는 것은 아닙니다.

현재 환경의 73차원 observation은 다음 순서입니다.

0:3    base linear velocity
3:6    base angular velocity
6:9    projected gravity
9:12   velocity command
12:14  gait phase sin/cos
14:15  target swing side
15:44  joint position - nominal   # runtime joint order
44:73  joint velocity             # runtime joint order

관절 순서를 틀리면 output 29개만 잘못되는 것이 아닙니다. actor가 읽는 joint position 29개와 velocity 29개의 의미도 함께 바뀝니다. 예를 들어 runtime index 1의 오른쪽 hip pitch encoder를 old profile의 왼쪽 ankle roll로 해석하면, actor는 잘못된 상태를 본 뒤 그와 다른 의미의 action을 냅니다. 관측과 행동의 오류가 한 loop 안에서 서로 강화될 수 있습니다.

이름으로 찾는 코드와 숫자를 직접 쓰는 코드가 함께 존재합니다

현재 코드가 모두 name-based인 것은 아닙니다. waist·hip roll/yaw·arm group은 runtime name lookup으로 index를 얻지만, 일부 swing-action reward와 trace는 검증된 runtime index를 직접 사용합니다. 예를 들어 왼쪽 hip pitch·knee·ankle pitch는 각각 0·9·13, 오른쪽은 1·10·14라는 전제입니다. v221b의 좌우 phase 비대칭을 조사할 때도 --print_joints로 이 여섯 index를 다시 확인했고, 당시 비대칭을 trace-index 오류로 설명할 수 없다고 판정했습니다.

hard-coded index 자체가 언제나 잘못은 아닙니다. tensor 연산에서는 이름을 매 step 검색하는 것보다 초기화 때 확정한 index를 쓰는 편이 자연스럽습니다. 문제는 그 숫자가 어느 asset revision과 ordered name list에서 만들어졌는지 기록하지 않는 것입니다. USD가 바뀌어 runtime order가 달라졌는데 코드의 0·9·13이 그대로라면, reward와 진단 trace부터 다른 관절을 측정할 수 있습니다.

그래서 index를 없애는 것보다 초기화 시 name→index를 resolve하고, 그 결과의 ordered hash가 기대값과 다르면 실행을 중단하는 구조가 필요합니다. find_joints()를 사용할 때도 Isaac Lab API의 preserve_order 동작을 명시해야 합니다. regex가 같은 이름 집합을 찾았다는 사실과 호출자가 요청한 순서대로 반환했다는 사실은 같지 않습니다.

v303 수정은 router를 simulation runtime과 다시 맞췄습니다

v303 integration에서는 robot-control-router/configs/robots/g1.yaml의 joint list를 live G1Walk 순서로 바꾸고, 같은 순서로 nominal q와 official Rev 1.0 q/tau limit을 다시 적었습니다. RLBackend는 profile의 nominal q를 우선 사용하고 q target을 q_min과 q_max 사이로 제한하도록 바뀌었습니다. HumanoidAdapter에도 같은 position clamp가 들어갔습니다.

수정 뒤 model_5467 candidate는 dummy-state smoke뿐 아니라 실제 G1Walk state를 읽은 강제 RL router step 3/3에서 status=ok를 반환했습니다. 150 control step, 즉 3초 동안 router q target을 다시 simulation action으로 바꾸는 짧은 closed-loop bridge도 immediate termination 없이 끝났습니다. 하지만 x 1.225m 이동과 함께 y 1.102m의 큰 lateral drift, yaw 1.201rad, action saturation이 남았습니다. 그래서 default router promotion은 차단됐습니다.

이 결과가 증명한 범위는 명확합니다. 수정된 profile로 checkpoint를 읽고 73D state에서 29D q target을 만들어 짧은 simulation loop를 닫을 수 있었습니다. joint order 수정만으로 policy가 배포 가능한 보행기가 됐다는 뜻은 아닙니다. shadow·canary·rollback 글에서 v303을 forward-only candidate로 남긴 이유도 여기에 있습니다.

checkpoint에는 action row의 이름이 자동으로 붙지 않습니다

현재 checkpoint loader는 network weight shape로 73과 29를 알아냅니다. 그러나 마지막 linear layer의 row 0이 left_hip_pitch_joint라는 사실은 weight tensor만 보고 복원할 수 없습니다. actor가 학습될 때 사용한 observation layout, runtime joint order, nominal q, action scale, group-specific composition까지 함께 있어야 row의 의미가 정해집니다.

따라서 compatibility는 filename이나 model_5467.pt의 SHA-256 하나로 충분하지 않습니다. 적어도 다음 sidecar가 checkpoint와 함께 고정돼야 합니다.

checkpoint_sha256
actor_observation_layout
actor_joint_names_ordered[29]
action_joint_names_ordered[29]
nominal_q[29] / action_scale[29]
unit[29] / sign[29] / zero_offset[29]
q_min[29] / q_max[29] / tau_max[29]
asset_id + asset_revision
environment_source_revision
normalizer_identity
action_interface_profile

관절별 배열은 모두 ordered name list에 종속됩니다. q limit만 올바른 숫자 29개라고 해도 순서가 다르면 hip에 wrist limit을 적용할 수 있습니다. 값의 범위 검사는 이런 오류를 반드시 잡지 못합니다. 더 넓은 limit이 잘못 연결되면 오히려 위험한 값이 정상으로 통과합니다.

Unitree motor index는 Isaac runtime 순서와 또 다릅니다

router profile이 G1Walk runtime과 같아졌다고 해서 실물 LowCmd.motor_cmd[i]에 그대로 복사할 수 있는 것은 아닙니다. Unitree의 공식 G1 motion 예제에 있는 29-DOF joint enum은 왼쪽 다리 0~5, 오른쪽 다리 6~11, 허리 12~14, 왼팔 15~21, 오른팔 22~28 순서입니다. 공식 unitree_mujoco도 simulator motor numbering이 실제 hardware에 대응한다고 설명합니다.

현재 Isaac runtime은 양쪽 hip과 waist를 앞에서 interleave한 뒤 knee, shoulder, ankle을 이어 붙입니다. 두 ordered list를 이름으로 대조하면 같은 index에서 같은 joint를 가리키는 곳은 0번 left hip pitch와 28번 right wrist yaw 두 개뿐입니다. 필요한 runtime→hardware mapping은 다음과 같습니다.

[0, 6, 12, 1, 7, 13, 2, 8, 14, 3, 9, 15, 22,
 4, 10, 16, 23, 5, 11, 17, 24, 18, 25, 19, 26, 20, 27, 21, 28]

이 배열은 runtime index i의 값을 hardware motor index mapping[i]에 쓴다는 뜻입니다. encoder q·dq를 actor observation으로 가져올 때는 역 permutation이 필요합니다. forward mapping만 맞고 feedback inverse가 틀리면 actor는 자신이 내린 명령과 다른 관절의 반응을 보게 됩니다.

현재 저장소에는 이 hardware bridge가 구현돼 있지 않습니다. HumanoidAdapter.apply_to_sim()도 여전히 값을 출력할 뿐이고 Unitree LowCmd publisher와 LowState readback을 연결하지 않습니다. 그러므로 위 permutation은 source lists에서 계산한 설계 입력이지, 실물 G1에서 readback까지 검증한 결과가 아닙니다.

호환성 manifest는 이름·순서·수치 의미를 한 번에 묶어야 합니다

ordered list를 문자열로만 저장하면 사람이 읽기는 쉽지만 drift를 자동 차단하기 어렵습니다. 제가 원하는 manifest는 producer, checkpoint, consumer 세 계약을 분리하고 각각의 ordered hash를 가집니다.

계층 정본 필수 검증 불일치 시 행동
producer Isaac live articulation joint names 29 unique names, runtime index, asset revision 환경 초기화 단계에서 중단
checkpoint 학습 당시 observation/action ordered names layout·normalizer·nominal·scale·interface hash policy load 거부
router robot profile joint names와 per-joint arrays producer와 exact ordered match 또는 명시적 permutation candidate route 비활성
hardware Unitree motor enum과 firmware/profile identity runtime↔motor bijection, inverse round trip publisher authority 0 유지

hash는 unordered set이 아니라 [[index, name], ...]의 canonical encoding에 계산해야 합니다. 같은 29개 이름을 다른 순서로 정렬하면 다른 hash가 나와야 합니다. per-joint nominal, scale, limit, gain도 같은 ordered name hash를 참조하게 해야 배열만 따로 복사되는 일을 막을 수 있습니다.

incident trace 글에서 다룬 M31 특수 경로에는 이미 이 방향의 선례가 있습니다. joint census의 runtime_index가 실제 list index와 다르면 실패하고, [[runtime_index, name], ...]의 joint-order SHA-256을 검사합니다. model metadata의 joint-name hash와 default q·action scale·Kp·Kd·effort·velocity 배열도 같은 joint records와 exact equality를 요구합니다. 다만 이것은 M31 capture·cross-simulator 계약에 구현된 것이며 일반 router와 checkpoint loader에 연결된 공통 gate는 아닙니다.

첫 검증은 로봇을 움직이는 pulse가 아니라 permutation을 깨뜨리는 test입니다

실물에서 한 관절씩 움직여 mapping을 확인하는 것은 마지막 단계여야 합니다. 잘못된 mapping을 찾기 위해 실제 모터에 잘못된 명령을 보내는 접근은 순서가 거꾸로입니다. 먼저 software-only test에서 일부러 오류를 만들어 preflight가 반드시 실패하는지 확인해야 합니다.

  1. exact-name test: producer·checkpoint·router의 name set이 같고 중복·누락이 없는지 검사합니다.
  2. ordered-hash test: 두 joint 이름만 맞바꾼 fixture가 dimension 29를 유지해도 load 전에 거부되는지 봅니다.
  3. bijection test: runtime→hardware mapping이 0~28을 한 번씩만 포함하는지, inverse와 합성했을 때 identity인지 확인합니다.
  4. semantic array test: nominal·scale·limit·gain을 이름으로 reorder한 결과와 manifest hash가 일치하는지 검사합니다.
  5. offline observation test: 이름이 붙은 synthetic q·dq를 hardware order에 놓고 inverse mapping한 뒤 73D observation의 15:44와 44:73에 정확히 들어가는지 확인합니다.
  6. simulation one-hot test: motor authority 없이 action index 하나만 변화시켜 예상한 joint target 하나만 바뀌는지, 다시 name-tagged readback으로 확인합니다.
  7. stale-profile test: v303 이전 alphabetical profile을 fixture로 넣어 semantic preflight가 실패하는 회귀 test를 남깁니다.

그 다음에야 hardware read-only LowState에서 각 motor index의 name mapping과 q·dq 단위·부호·zero offset을 확인할 수 있습니다. 실제 LowCmd를 보내는 검증은 vendor의 안전 절차, mode ownership, 지지 장치와 별도 승인을 갖춘 뒤에도 가장 작은 범위로 제한해야 합니다. 이 글에서 hardware pulse를 실행했다고 주장하지 않습니다.

현재 통과한 것과 아직 비어 있는 gate

gate 현재 증거 판정
G1Walk live joint order 확인 --print_joints, v221b·v303 기록 실행·확인
router profile의 runtime order 수정 v303 config와 nominal·limit 재정렬 구현
수정 뒤 live-state router smoke 3/3 forced-RL step, obs/action 73/29 실행·관찰
수정 뒤 짧은 sim closed loop 150/150 step 생존, 큰 drift와 saturation 실행, 배포 미승인
일반 checkpoint ordered-name manifest loader는 weight shape만 추론 미구현
router↔Unitree motor permutation bridge source list 대조만 완료 설계, 미구현
실물 readback·command parity hardware adapter와 telemetry 없음 미실행

URDF와 USD 글은 어떤 asset과 actuator가 물리를 만드는지 다뤘고, action→PD 글은 한 action 값이 위치 목표와 torque로 변하는 과정을 다뤘습니다. 이번 글의 경계는 그 사이에 있습니다. 그 action 값이 어느 관절의 값인지를 producer부터 checkpoint, router, hardware까지 잃지 않는 문제입니다.

다음 목표는 잘못된 29차원을 조용히 통과시키지 않는 것입니다

v303에서 가장 위험했던 신호는 crash가 아니라 status=ok였습니다. 차원이 맞았기 때문에 interface는 정상처럼 보였습니다. 실제 runtime name을 출력하고 나서야 old router list가 다른 로봇 의미를 갖고 있다는 사실을 확인했습니다.

다음 구현은 새 policy 학습이 아닙니다. live articulation에서 ordered joint manifest를 만들고 checkpoint sidecar·router profile과 hash를 대조하는 preflight부터 추가해야 합니다. 첫 회귀 test는 일부러 두 이름을 바꾼 29차원 profile입니다. 그 profile이 policy inference 전에 실패하고 actuator authority가 0으로 남아야 합니다.

로봇 제어에서 29는 크기입니다. 29개의 이름, 순서, 단위, 부호, 기준 자세가 함께 고정돼야 비로소 29개 관절이 됩니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤