11 Commits

Author SHA1 Message Date
robin b7f8ed519c Rewrite LIDAR/SLAM plan doc to reflect fori_ws's existing implementation
The plan assumed no code existed yet, but the 2.5D-map autonomy stack
(URDF, terrain-aware speed control, Nav2 global/local costmaps +
collision monitor) is already built and running in the separate
fori_ws workspace. Reframes the doc around what's actually there and
the concrete gaps found (AMCL correction not wired into the nav TF
tree, skid-steer calibration not ported from xbox_motor_control.cpp).
2026-08-26 00:01:58 +09:00
robin 6350661c66 Add LIDAR/SLAM plan and improvements report docs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:00:39 +09:00
robin 9a97cf9022 Add IMU integration (Phases 1-5a) and RC/kinematics reliability fixes
IMU integration (WitMotion HWT905-RS232, doc/06-imu-integration-plan.md):
- Phase 1: raw accel/gyro/angle observation, power-on-relative yaw offset
- Phase 2: x,y odometry + wheel-vs-IMU omega residual, using a self-fused
  heading (wheel encoder omega + raw gyro_z average) instead of the IMU's
  own onboard fused yaw, which field logs showed disagreeing with its own
  raw gyro sign ~30% of the time during turns
- Phase 3: straight-line heading-hold PI trim, active only when steering
  is centered and no lidar dodge is in progress, capped and field-tuned
- Phase 4: whole-body slip detection (commanded vs IMU-measured omega
  ratio) that temporarily scales down v_x/omega, independent of the
  per-wheel current-based diagnostics
- Phase 5a: k_skid/effective_w replaced with values fitted from real
  wheel-vs-IMU logs (0.406->0.51, 0.684->0.87) instead of the geometric
  formula, which field data showed under-driving every turn

RC receiver: switched from ttyUSB* guessing to udev-stable device names
(ttyMOTOR/ttyRC/ttyIMU), wired through run_4wd.sh and set_low_latency.sh.

Motor driver: read motor/driver temperature registers (0x20A4/0x20B0)
for proactive overheat visibility in the HUD/CSV.

Control fixes:
- Airborne-wheel zeroing no longer applies during spin turns, where
  reaction-torque-driven diagonal load transfer was misclassified as
  wheels leaving the ground and cutting spin torque in half
- jerkLimitedStep's instant-acceleration path now only fires for
  same-direction speed increases; sign reversals (RC noise/deadzone
  jitter near a stop, or a genuine direction change) go through the
  jerk-limited path instead of snapping across zero

Auto CSV logging (logs/, gitignored) extended with encoder ticks, IMU,
odometry, heading-hold, slip, and temperature columns for field analysis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:20:43 +09:00
robin d5a47881ac Fix RC steering sign inversion and throttle/steer slew-filter livelock
Steering (CH1): omega was computed with the opposite sign from the
convention used everywhere else in this file (+omega=left,
-omega=right, per the LiDAR avoidance comments and the prior Xbox
joystick code), so pushing the stick left steered the robot right
and vice versa. Flipped the sign at the single point omega is
derived from the stick so curve-turn and spin-turn both inherit the
fix.

Throttle/steer anti-corruption filter: once a reading was rejected
for exceeding max_slew_per_tick, last_raw_throttle/last_raw_steer
never updated, so every subsequent real reading kept failing the
same slew check against that now-permanently-stale reference —
a livelock. Symptom: CH2 showing neutral (1500) on the HUD while the
robot kept driving forward regardless of stick input. Added a
max_reject_streak escape hatch: after a few consecutive rejections
in a row, treat it as a real fast stick movement rather than a
one-off corrupted frame and resync to the latest value.

Verified working on hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:30:16 +09:00
robin dec1d85f9b change the controler 2026-08-19 14:44:25 +09:00
robin 6be30eb929 add doc 2026-08-19 13:56:22 +09:00
robin bb5a0adc0c Replace Xbox joystick control with RadioMaster Pocket + XR1 RC receiver
Migrates the 4WD control input from SDL2/Xbox gamepad to a serial RC
receiver per doc/joystick.md's functional spec: CH1 steering, CH2
throttle, CH5 emergency brake (highest priority), CH7 speed mode
(0.3/0.6 m/s), with a 200ms fail-safe that forces an immediate stop
on signal loss. Channel mapping, deadzones, port, and baudrate are
all configurable via --rc_* CLI flags. LiDAR avoidance, jerk-limited
motion profile, and wheel-fault detection are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 12:44:51 +09:00
Dongubak dd77dbf1bb add manual of IMU
add manual of IMU
2026-08-18 10:27:13 +09:00
Dongubak 2f21d86193 Add ZLAC8015D driver docs, IMU integration plan, and reference resources
Documents the ZLAC8015D V4 dual-channel driver (hardware spec, RS485
Modbus register map, 4WD skid-steer system architecture, the
xbox_motor_control.cpp control loop, and error/troubleshooting) under
doc/, plus a phased plan for adding IMU-based heading/slip feedback
(doc/06) informed by Clearpath Husky's sensor fusion stack and
skid-steer ICR literature. QnA/ records the Q&A that refined that plan.
Also adds the underlying manual PDFs and reference links under
resource/ that the docs cite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:17:24 +09:00
robin ed34e1c025 Snap wheel command to target immediately on landing after airborne
Previously, once a wheel's airborne flag cleared, its target reverted
to the normal drive command but the actual RPM still had to ramp up
through the gentle standstill-launch accel_rate/jerk profile. Since
the chassis is already moving at that point (unlike a stop-start),
the wheel spent several hundred ms unable to match ground speed,
dragging/grinding against the surface and occasionally landing on a
momentarily negative curve-turn target, making it spin backward.

Track the airborne->grounded transition per wheel and, on the tick it
clears, snap cmd directly to target and reset the jerk accel state
instead of ramping, so the wheel rejoins the other three immediately.

Verified working on hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 13:22:29 +09:00
robin 19fb70d998 Fix curve-steering wheel-reversal at partial throttle
Steering angular velocity (omega) was scaled by the constant max
speed (max_v) instead of the actual current forward speed (v_x).
This decoupled turn tightness from throttle position: at low/medium
throttle, a moderate left-stick steering input already produced an
omega large enough to flip the inner wheel's commanded velocity
negative, causing the robot to spin/reverse/lurch forward instead of
smoothly curving. Scaling by abs(v_x) keeps turn radius proportional
to speed as intended, matching prior full-throttle behavior while
fixing the reversal at partial throttle.

Also drops the stale committed x86-64 binary (built before this fix,
so its behavior no longer matched source). Build fresh on an x86
machine via `make`; run_4wd.sh already rebuilds automatically when
the binary is missing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:56:33 +09:00
33 changed files with 3471 additions and 190 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
logs/
+1 -1
View File
@@ -1,6 +1,6 @@
CXX = g++
CXXFLAGS = -O3 -std=c++17 -Wall -Wextra -I/usr/local/include
LIBS = -lSDL2 -lpthread -llivox_lidar_sdk_shared
LIBS = -lpthread -llivox_lidar_sdk_shared
TARGET = xbox_motor_control_cpp
SRC = xbox_motor_control.cpp
+187
View File
@@ -0,0 +1,187 @@
## A (q1.md에 대한 답변)
`doc/06-imu-integration-plan.md`에 대한 4가지 질문에 순서대로 답한다. 결론을 먼저 요약하면:
**개루프(조이스틱)와 폐루프(IMU 트림)는 상충하지 않으며 실무에서도 계층 구조로 함께 쓰인다.
ICR은 실제로 동적이며 9축 IMU로 온라인 추정이 가능하다. 헤딩 홀드는 상대 헤딩 기준 + 최소한의
필터링으로 충분하다. HWT905는 RS485/Modbus를 지원해 기존 통신 스택 재사용이 가능하다.**
---
### Q1. 조이스틱 폐루프 제어가 실무에서 실제로 쓰이는가?
**결론: 쓰인다. 다만 사용자가 걱정하는 "조이스틱 값이 바뀌면 폐루프 계산을 처음부터 다시 해야
한다"는 문제는 애초에 발생하지 않는다.** 그 걱정 자체가 폐루프 제어에 대한 흔한 오해를 짚고
있어서, 왜 문제가 안 되는지부터 설명한다.
#### 왜 "재계산 문제"가 없는가
폐루프 제어의 기본 형태는 다음과 같다.
```
e(t) = r(t) y(t) // r(t): 기준값(레퍼런스), y(t): 센서 측정값
u(t) = Controller(e(t)) // 그 순간의 오차만 보고 그 순간의 보정량을 계산
```
여기서 `r(t)`(조이스틱이 매 순간 주는 목표값)가 틱마다 바뀌는 것은 폐루프 제어의 정상적인
동작 방식이지, 예외 상황이 아니다. 제어기는 "이전 계산이 끝날 때까지 기다렸다가 다음 값을
반영"하는 게 아니라, **매 사이클(본 프로젝트는 100Hz=10ms)마다 그 순간의 `r(t)``y(t)`만으로
그 순간의 보정량 하나를 계산**한다. 지금 이미 코드에 있는 `jerkLimitedStep()`도 정확히 이 구조다
`target_*`(조이스틱에서 매 틱 갱신되는 레퍼런스)가 바뀌어도 다음 틱에서 그냥 새 `target_*`
기준으로 다시 한 스텝 계산할 뿐, "이전 목표를 향한 계산이 안 끝났으니 못 바꾼다"는 일이 없다.
헤딩 홀드도 동일한 구조로 넣을 수 있다.
#### 이미 이 프로젝트에도 폐루프가 존재한다
"조이스틱 제어 = 개루프"라는 인식은 절반만 맞다. 계층을 나눠보면:
| 계층 | 신호 흐름 | 개/폐루프 |
| :--- | :--- | :-: |
| 상위 (조이스틱 → 목표 RPM) | `ly/lx/rx` → 스키드 스티어 킨매틱스 → `target_fl/fr/rl/rr` | **개루프** (피드포워드) |
| 중위 (목표 RPM → 지령 RPM) | 저크 제한 S-curve (`jerkLimitedStep`) | 개루프 프로파일 생성 (폐루프 아님, 단 매 틱 최신 목표 반영) |
| **하위 (지령 RPM → 실제 RPM)** | **드라이버 내장 Velocity Loop** (`0x203C`/`0x203D`/`0x203E` L, `0x206C`/`0x206D`/`0x206E` R — Kp/Ki/Kf) | **이미 폐루프.** 부하가 걸려도 드라이버가 실제 RPM을 지령 RPM에 맞추려 함 |
즉 지금도 "조이스틱 값 → 목표 속도"까지는 개루프지만, "목표 속도 → 실제 바퀴 속도"는 이미
드라이버가 폐루프로 하고 있다. IMU 헤딩 홀드(계획서 Phase 3)는 이 구조에 **한 계층을 더
얹는 것**뿐이다: "조향 입력이 중립일 때, 목표 요레이트(=0)와 실측 요레이트(IMU)의 오차를 보고
좌/우 목표 RPM에 작은 트림을 더한다." 조이스틱이 다시 움직이면(조향 입력 재개) 그 즉시 헤딩
락을 풀고 트림을 0으로 되돌리면 되므로, "조이스틱 값 변경 시 재계산" 문제는 설계상 발생하지
않는다.
#### 실무 사례 (동일 패턴)
* **자동차 ESC/TCS(전자식 주행안정장치)**: 운전자의 스티어링·가속페달이 항상 최우선 레퍼런스지만,
요레이트 센서가 실측한 값이 스티어링 각도로부터 기대되는 값과 어긋나면 개별 바퀴 브레이크/토크를
트림한다. 운전자가 스티어링을 계속 바꿔도 문제없이 동작 — 매 사이클 그 순간의 편차만 본다.
* **쿼드콥터**: 스틱은 각속도/자세에 대한 레퍼런스이고, 내부 자세제어 루프가 IMU로 안정화한다.
스틱을 중립으로 놓으면 자세/포지션 홀드로 전환되어 바람 등 외란을 자동 보정 — Phase 3와 정확히
동일한 "중립 시에만 홀드" 패턴.
* **선박 오토파일럿**: 조타핸들이 중립일 때만 자이로컴퍼스 기준 헤딩홀드가 개입하고, 조타를
움직이면 즉시 새 헤딩으로 재설정된다.
**요약**: 실무의 표준 패턴은 "조이스틱 = 상위 레퍼런스(운전자 의도, 매 틱 갱신 가능) + 하위
폐루프(그 레퍼런스를 추종·안정화)"의 계층 구조다. 계획서 Phase 3가 제안하는 헤딩 홀드도 이
패턴을 그대로 따른다.
---
### Q2. ICR이 동적이라면, IMU로 이를 감지해 더 고차원의 제어가 가능한가?
**결론: 가능하다. 그리고 지적한 대로 현재 로직은 "동적으로 변하는 현상을 정적 스칼라로 땜빵"한
것이 맞다.**
#### 현재 로직이 "땜빵"인 이유 (재확인)
`k_skid`, `effective_w`는 로봇 형상(트랙폭, 축거)만으로 계산된 기하학적 근사치이고,
`spin_arc_bias=0.12`는 실측 없이 경험으로 넣은 상수다. 이 값들은 지면 마찰계수, 무게중심,
경사, 타이어 트레드 마모, 속도에 따라 실제로는 계속 바뀌는 "유효 ICR"을 대표할 수 없다
— 특정 노면/속도 조건에서 튜닝된 값일 뿐이다.
#### 9축 IMU가 추가하는 정보 차원
| IMU 축 | 얻는 정보 | 현재(전류+엔코더)로는 불가능한 이유 |
| :--- | :--- | :--- |
| 자이로 요레이트 | 로봇이 **실제로** 얼마나 회전하고 있는가 (슬립 무관) | 전류는 부하/토크 정보, 엔코더는 개별 바퀴 회전량 정보일 뿐 — 차체가 실제로 얼마나 돌았는지는 아무도 안 알려준다 |
| 가속도계 (중력보정 후) | 급조향/급가감속 시 차체가 실제로 그만큼 가속했는가 | 저크 리미터가 만드는 "기대 가속도"와 비교해 "명령은 갔는데 차체가 안 따라옴" 상태(전신 슬립)를 검출 가능 |
| Roll/Pitch(경사각) | 현재 지형이 경사면인지 | 경사에서는 좌우 바퀴 접지압이 비대칭이 되어 ICR이 더 크게 틀어짐 — 지금은 이 정보 자체가 전무 |
| 지자기 | 절대 방위 참조 (제한적) | 다만 차체가 모터/드라이버/배터리 등 금속 덩어리라 지자기 왜곡이 클 가능성이 높음 — 절대 헤딩보다는 참고용으로만 신중히 사용 권장 |
#### "정적 캘리브레이션"보다 한 단계 더 — 온라인 ICR 추정
계획서 Phase 5(§6.4)는 원래 "몇 차례 실측 후 한 번 피팅해서 상수를 교체"하는 **정적**
캘리브레이션으로 설계했다. 하지만 지적한 대로 ICR이 지형에 따라 계속 바뀐다면, 매 틱
```
effective_ICR_gain = 실측_요레이트(IMU) / 지령_차동속도
```
를 계속 갱신하는 **온라인 추정**(Pentzer 2014가 제안한 방식)이 이론적으로는 더 맞는 방향이다.
다만 이건 Phase 5를 정적 캘리브레이션 → 상시 온라인 추정으로 확장하는 것이므로, 구현 난이도와
실외 검증 부담이 커진다. 이번 답변을 반영해 계획서 §6.4 Phase 5에 "1차: 정적 캘리브레이션으로
먼저 검증 → 2차: 온라인 추정으로 확장 가능"이라는 단계를 추가해 두었다(아래 "계획서 반영"
참고).
#### 우선순위: 조이스틱 제어가 먼저라는 원칙과의 정합성
이 답변에서 제안하는 내용은 계획서의 Phase 순서를 바꾸지 않는다. Phase 1~2(§6.4)는 IMU 값을
"관측/로깅"만 할 뿐 제어에 전혀 개입하지 않으므로, 조이스틱 제어 자체의 완성도에는 영향을 주지
않는다. ICR 온라인 추정 같은 고차원 활용은 Phase 5 이후, 즉 조이스틱 경로가 충분히 검증된
다음에야 의미가 있는 단계로 이미 순서가 잡혀 있다.
---
### Q3. 헤딩 기준을 IMU로 얻어 직진 편향 보정이 가능한가? 필터는?
**결론: 맞다. 그리고 이 용도에서는 "정밀한 절대 헤딩"이 아니라 "짧은 구간 동안의 상대 헤딩"만
있으면 되기 때문에, 필터링 요구 수준이 생각보다 무겁지 않다.**
#### 왜 절대 헤딩이 필요 없는가
Phase 3(§6.4)의 헤딩 홀드는 "조향 입력이 중립인 동안 로봇이 옆으로 틀어지지 않게" 하는 용도다.
조향 입력이 다시 들어오면(사용자가 스틱을 움직이면) 헤딩 락을 풀고, 다시 중립이 되는 시점에
**그 순간의 방향을 새 기준으로 재설정(reset)**하면 된다. 즉 각 "직진 구간"은 보통 수 초~수십 초
단위이므로, 자이로 바이어스 드리프트가 누적될 시간 자체가 짧아 실사용상 큰 문제가 되지 않는다.
(장기 절대 헤딩이 필요해지는 건 계획서 Phase 6의 자율주행/맵핑 단계부터다.)
#### 그래도 필요한 최소한의 처리
1. **정적 바이어스 캘리브레이션**: 전원 인가 직후 정지 상태에서 수 초간 자이로 값을 평균 내
영점(zero-rate offset)을 구해 빼준다. 이거 없이는 정지 중에도 헤딩이 서서히 흘러간다.
2. **노이즈 필터링**: 실외 오프로드 특성상 진동이 크다. 저역통과 또는 이동평균 정도로 고주파
진동 성분을 제거해야 트림 제어기가 노이즈에 반응해 떨지 않는다.
3. **HWT905류 온보드 AHRS 모듈을 쓰면 위 부담이 상당 부분 줄어든다**: 이 모듈은 가속도+자이로
+지자기를 내부적으로 칼만필터 등으로 융합해 이미 안정화된 roll/pitch/yaw를 직접 출력한다.
순수 MEMS 칩(raw 자이로 값만 주는 IC)을 쓰는 것보다 애플리케이션 쪽 필터링 구현 부담이
훨씬 적다 — Q4에서 이어서 설명.
---
### Q4. IMU 하드웨어: HWT905 확정
WitMotion 공식 자료 기준으로 확인한 HWT905-RS485 스펙 (정식 PDF 매뉴얼은 이미지 기반이라
텍스트 자동 추출이 안 돼, 아래는 제조사 웹페이지 기준 요약이다 — 레지스터 맵 등 세부 사항은
§실행 전 확인 필요 참고):
| 항목 | 내용 |
| :--- | :--- |
| 측정 축 | 9축 — 가속도 3축 + 자이로 3축 + 지자기 3축, 내장 자세융합 알고리즘으로 roll/pitch/yaw 직접 출력 |
| 통신 | RS485, **Modbus 프로토콜 지원**(레지스터 리스트 기반) |
| 보드레이트 | 기본 9600bps, 2400~921600bps 가변 |
| 출력 레이트 | 최대 200Hz |
| 정확도 | 제조사 공식 스펙 "0.05°" (동적/정적 조건은 정식 PDF 확보 후 재확인 필요) |
| 케이스 | 55×36.8×24mm 알루미늄, **IP67 방수/방진** |
| 케이블 | RS485 최대 30m |
#### 이 프로젝트에 잘 맞는 이유
1. **RS485 + Modbus 프로토콜**`xbox_motor_control.cpp``SerialPort` 클래스(Modbus RTU,
CRC16, `readReg`/`writeReg`/`readRegs` 패턴)를 상당 부분 재사용할 수 있다. IMU만을 위한
완전히 새로운 통신 스택을 만들 필요가 없다. (단, ZLAC8015D 2대가 이미 쓰고 있는 버스에
같이 물릴지, 별도 물리 포트로 분리할지는 §실행 전 확인 필요에서 결정해야 한다 — 100Hz
루프에서 통신 3개 슬레이브를 한 버스로 돌리면 틱당 통신 시간이 늘어날 수 있다.)
2. **IP67 방수/방진** — 실외 로봇이라는 조건에 부합.
3. **온보드 AHRS 융합** — Q3에서 설명한 필터링 부담을 하드웨어가 상당 부분 대신 처리해준다.
저가 MEMS 칩 대비 확실한 이점.
#### 실행 전 확인 필요 (아직 확정 못한 것)
이번 조사에서 확보한 HWT905 정식 매뉴얼 PDF는 이미지 스캔본이라 텍스트 추출이 되지 않아,
**정확한 레지스터 주소(각속도/각도/가속도/지자기 레지스터, 출력레이트 설정 레지스터, 슬레이브
주소 설정 방법 등)는 이번 문서에 포함하지 못했다.** ZLAC8015D 때와 동일한 방식으로 진행하려면:
1. HWT905 정식 매뉴얼(텍스트 검색 가능한 PDF)을 `resource/`에 추가.
2. `doc/07-imu-hwt905-spec.md`(가칭)로 레지스터 맵을 ZLAC8015D 문서와 동일한 정밀도로 문서화.
3. 그 다음에야 Phase 1(하드웨어 연동) 코드 작업 착수.
---
## 계획서(`doc/06-imu-integration-plan.md`) 반영 사항
이 답변 내용을 반영해 계획서를 다음과 같이 갱신했다:
* §6.7 "결정이 필요한 사항" 중 **IMU 하드웨어 항목을 HWT905로 확정** 표시.
* Phase 5에 "정적 캘리브레이션 → 온라인 ICR 추정으로 확장 가능" 단계 구분을 추가.
* Phase 1 앞에 "IMU 정식 매뉴얼 확보 및 레지스터 맵 문서화"를 선행 작업으로 명시.
남은 결정 사항: **IMU를 ZLAC8015D와 같은 RS485 버스에 물릴지, 별도 포트로 분리할지** — 이건
실측(버스 하나에 3개 슬레이브 얹었을 때 100Hz 루프의 `comm_ms` 여유가 있는지) 후 결정하는 것을
제안한다.
+11
View File
@@ -0,0 +1,11 @@
## Q
06-imu-integration-plan.md 문서를 읽어보았습니다.
현재 코드를 분석한 결과 제가 알 수 있는 것은 다음과 같았습니다.
- 현재 개루프 텔레옵이다. 하지만 조이스틱으로의 제어를 먼저 완성하려는 입장에서 스키드 스티어 차량의 경우 개루프일 수 밖에 없는 것이 아닌가. 가령 폐루프를 그린다고 하면 폐루프 계산을 진행하고 다음 제어를 할 때 조이스틱의 값이 변경되면 다시 계산해야 할텐데, 나도 이런 제어방식이 있어야 완벽한 조이스틱 제어가 완성될 것이라고 생각한다. 하지만 실무에서 실제로 조이스틱 제어가 폐루프로 이루어지는 지 알고 싶다.
- ICR은 로봇이 있는 곳의 경사 그리고 무게중심, 지형 특성, 바퀴의 트레드 등 여러 요소에 의존되어 다이나믹하게 변할 것이다. 현재 로직은 분석한 문서를 보면, 변화무쌍한 ICR을 고정된 스칼라값을 입력하여 땜빵식으로의 보정을 한 것이라고 이해하였다. 그렇다면 IMU를 추가하여 ICR을 다이나믹하게 감지하여 전류와 현재 회전량 뿐만아니라 9축 IMU를 통해 고차원의 제어가 가능한 것인가. 일단 조이스틱 제어가 우선이다.
- 헤딩 기준이 없다고 했는데 헤딩 기준 또한 IMU를 통해 얻을 수 있으므로, 직직시 편향 또한 보정이 가능한 것으로 보인다. 물론 IMU는 필터가 필요할 것이다.
- IMU는 MCU 내장 등 초 저가형은 사용하지 않을 것이며, HWT905 같은 IMU를 사용할 것이다.
+207
View File
@@ -0,0 +1,207 @@
# 01. ZLAC8015D V4 하드웨어 스펙
출처: `resource/ZLAC8015D V4 Series Manual Version 1.03-20251111.pdf`
## 1.1 개요
* 제조사: Shenzhen Zhongling Technology (ZLTECH)
* 허브 서보 모터 전용 디지털 AC 서보 드라이버로, **1대가 좌/우 모터 2개(듀얼 채널)를 동시 구동**한다.
* RS485(Modbus RTU) 및 CANopen(CiA301/CiA402) 버스 통신을 내장.
* 위치/속도/토크 제어 모드를 지원.
* 용도: AGV, 배송 로봇, 서비스 로봇, 자동화 이송 장비 등 — 본 프로젝트에서는 실외 스키드 스티어
4WD 로봇의 좌우 휠 페어 구동에 사용된다.
## 1.2 전기 사양
| 파라미터 | 최소 | 표준 | 최대 | 단위 |
| :--- | :---: | :---: | :---: | :--- |
| 입력 전압 | 20 | 36 | 48 | VDC |
| 출력 전류 (피크) | 0 | 15 | 30 | A |
| 제어 신호 입력 전류 | 7 | 10 | 16 | mA |
| 과전압 보호 | - | 75 | - | VDC |
| 저전압 보호 | - | 16 | - | VDC |
| 입력 신호 전압 | - | 5 | - | VDC |
| 절연 저항 | 18 | 20 | - | MΩ |
> 채널당 정격전류 150(=15.0A), 최대전류 300(=30.0A)이 레지스터 `0x2033/0x2034`(좌),
> `0x2063/0x2064`(우)의 공장 기본값이며, 위 표의 15A/30A와 일치한다.
> 자세한 내용은 [05-error-troubleshooting.md](05-error-troubleshooting.md)의 전류 한계 절 참고.
## 1.3 환경 사양
| 항목 | 내용 |
| :--- | :--- |
| 냉각 방식 | 자연 냉각 또는 강제 냉각 |
| 사용 장소 | 먼지, 유분(oil mist), 부식성 가스 회피 |
| 동작 온도 | 0 ~ 50 ℃ |
| 최대 습도 | 90% RH (결로 없음) |
| 보관 온도 | -10 ~ 70 ℃ |
| 진동 | 10~55Hz / 0.15mm |
실외 로봇에 탑재되므로 방진/방수 인클로저 내부에 설치하고, 방열이 충분한 방향(narrow side,
방열판 면)으로 장착할 것을 매뉴얼이 권장한다. 주변 온도가 60℃를 넘는 밀폐/금속 분진 환경은
피해야 한다.
## 1.4 외형/설치 치수
* 크기: 150 × 97 × 31mm 내외 (방열판 폭 제외 케이스 기준, `resource/ZLAC8015D V4 Series.pdf` 도면 참고)
* M3 나사로 4모서리(와이드 사이드) 또는 양측면(네로우 사이드) 고정. 방열을 위해 네로우 사이드
설치 권장.
* 장착 시 진동/충격 보호 필요 (차체에 완충 마운트 권장).
## 1.5 커넥터 핀맵
### J1/J2 — 좌측 모터 전원 (5핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1 | DC | 전원 입력 24-48V |
| 2 | GND | 전원 GND |
| 3 | U | 좌모터 U상 |
| 4 | V | 좌모터 V상 |
| 5 | W | 좌모터 W상 |
### 우측 모터 전원 (5핀, 핀 순서 반대)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 5 | GND | 전원 GND |
| 4 | DC | 전원 입력 24-48V |
| 3 | W | 우모터 W상 |
| 2 | V | 우모터 V상 |
| 1 | U | 우모터 U상 |
전원은 좌/우 커넥터 중 한쪽 또는 양쪽 동시 입력이 가능하다(퀵스타트 가이드 확인).
### J2/J6 — 좌/우 모터 인크리멘탈 엔코더 & 홀 (12핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1-4 | iA+/iA-/iB+/iB- | 인크리멘탈 엔코더 A/B상 |
| 5-6 | RTC+/RTC- | 온도 센서 |
| 7-8 | V/W (Hall) | 홀 센서 |
| 9 | U (Hall) | 홀 센서 |
| 10 | GND | 전원 GND |
| 11 | VCC | 엔코더/홀 전원 출력 |
| 12 | GND | 전원 GND |
### J3 — 모터 제어 신호 포트 (8핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1 | BGND-L | 좌 브레이크 전원- |
| 2 | -BR-L | 좌 브레이크- |
| 3 | BDC-L | 좌 브레이크 전원+/브레이크+ |
| 4 | BGND-R | 우 브레이크 전원- |
| 5 | -BR-R | 우 브레이크- |
| 6 | BDC-R | 우 브레이크 전원+/브레이크+ |
| 7 | OUTPUT1 | 내부 풀업 5V 출력 (CAN/RS485로 기능 설정 가능) |
| 8 | OUTPUT2 | 내부 풀업 5V 출력 (CAN/RS485로 기능 설정 가능) |
전자식 브레이크는 레지스터 `0x201A`(B0, 좌)/`0x201B`(B1, 우)로 제어한다
(`0`=해제, `1`=잠금). 코드의 `MotorDriver::setBrakes()`가 이 두 레지스터에 동시에 쓴다.
### J4 — 모터 제어 신호 포트 (8핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1 | AOUT-R | 우모터 엔코더 A 출력 |
| 2 | BOUT-R | 우모터 엔코더 B 출력 |
| 3 | AOUT-L | 좌모터 엔코더 A 출력 |
| 4 | BOUT-L | 좌모터 엔코더 B 출력 |
| 5 | +5V | 엔코더 5V 출력 (<100mA) |
| 6 | GND | 엔코더 GND |
| 7 | INPUT1 | 프로그래머블 입력 (기본 비상정지 등으로 설정 가능) |
| 8 | INPUT2 | 프로그래머블 입력 |
INPUT1/INPUT2 펄스폭은 10ms 이상 유지해야 드라이버가 정상 인식한다. 입력 레벨은 기본 5V이며
12V 사용 시 1kΩ/0.5W, 24V 사용 시 2kΩ/0.5W 전류제한 저항을 외부에 추가해야 한다.
### J5 — 통신 포트 (5핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1 | CANH | CAN |
| 2 | CANL | CAN |
| 3 | SGND | 통신 GND |
| 4 | A | RS485 A |
| 5 | B | RS485 B |
**본 프로젝트는 이 중 SGND/A/B 3선을 사용해 RS485 Modbus RTU 통신을 한다.** 통신선은
차폐 트위스트페어 사용 및 접지 연결을 매뉴얼이 권장한다.
### eRS-R/eRS-L — 절대값 엔코더 인터페이스 (4핀, 미사용)
RS485 방식 외장 절대값 엔코더용 인터페이스. 본 로봇은 인크리멘탈 엔코더 기반 속도제어만
사용하므로 미배선.
### SW — DIP 스위치 (2핀)
| SW | 기능 |
| :-: | :--- |
| SW1 | CAN 종단저항 선택 |
| SW2 | RS485 종단저항 선택 |
데이지체인 마지막 드라이버에서만 종단저항을 ON 하는 것이 일반적인 RS485 배선 원칙이다.
### RES — 회생 저항 입력 (2핀)
| Pin | 표기 | 기능 |
| :-: | :--- | :--- |
| 1 | R- | 브레이크 저항 |
| 2 | R+ | 브레이크 저항 |
100RPM 이상 속도 또는 급정지/비상정지 기능을 사용할 경우, 급감속 시 발생하는 역기전력으로부터
드라이버를 보호하기 위해 외부 회생 저항(권장 5~10Ω/100W) 연결을 권장한다. 본 로봇은 최고속도
0.4m/s(둘레 반경 131.5mm 기준 약 29RPM)로 저속 운용이라 리스크는 낮지만, 급제동(decel 150ms)
프로파일을 쓰므로 실외 장시간 운용 시 회생 저항 장착을 검토할 가치가 있다.
## 1.6 상태 LED
* **녹색 LED**: 전원 표시. 전원 인가 시 항상 점등.
* **적색 LED**: 알람 표시. 알람 발생 시 코드 횟수만큼 점멸 후 일시정지, 반복.
| 상태 | 점멸 횟수 | 원인 |
| :-: | :-: | :--- |
| Over-Voltage | 1 | 공급 전압이 최대 정격 초과 |
| Under-Voltage | 2 | 공급 전압이 최소 동작 전압 미만 |
| Over-Current | 3 | 상간 단락 등으로 모터 상전류 초과 |
| Over-Load | 4 | 상전류가 설정 과부하 전류 초과 |
| Current out-of-tolerance | 5 | (예약) |
| Position out-of-tolerance | 6 | 목표 위치와 출력 위치 오차 초과 |
| Speed out-of-tolerance | 7 | 목표 속도와 출력 속도 오차 초과 |
| Internal reference error | 8 | 드라이버 내부 오류 |
| Parameter reading error | 9 | EEPROM 파라미터 읽기 오류 |
| Hall fault | 10 | 홀 케이블 미접속 또는 신호 오류 |
| High motor temperature | 11 | 모터 과열 |
| Encoder error | 12 | 엔코더 케이블 헐거움/핀 순서 오류 |
| Driver overtemperature | 13 | 드라이버 과열 |
| Speed setting error | 14 | 지령 속도가 정격 속도 초과 |
| Mixed error | 15 | 2개 이상 알람 동시 발생 |
RS485로는 이 상태를 레지스터 `0x20A5`(좌)/`0x20A6`(우)에서 비트마스크로 읽을 수 있으며,
`xbox_motor_control.cpp``decodeDriverError()`가 이를 한글 문자열로 변환한다. 상세는
[05-error-troubleshooting.md](05-error-troubleshooting.md) 참고.
## 1.7 배선 요약도 (본 프로젝트 기준)
```
┌─────────────────────────┐
배터리 24-48V ─┤ DC/GND (좌 또는 우 or 양쪽) │
│ │
좌측 허브모터 ─┤ U/V/W (좌) │ 드라이버 #1 (Station ID 1)
│ │ → 전방 좌/우 모터 구동
우측 허브모터 ─┤ U/V/W (우) │
│ │
좌 엔코더/홀 ─┤ J2 │
우 엔코더/홀 ─┤ J6 │
│ │
RS485 A/B/GND ┤ J5 ── (데이지체인) ──────┼──→ 드라이버 #2 (Station ID 2)
│ │ → 후방 좌/우 모터 구동
좌/우 브레이크 ─┤ J3 │
└─────────────────────────┘
/dev/ttyUSB0 (RS485-USB 컨버터)
제어 PC (xbox_motor_control_cpp)
```
+274
View File
@@ -0,0 +1,274 @@
# 02. RS485 통신 프로토콜 & 레지스터 맵
출처: `resource/ZLAC8015D V4 Series RS485 Communication Version 1.06-20251111.pdf`,
`xbox_motor_control.cpp`
## 2.1 시리얼 포트 설정
| 항목 | 값 |
| :--- | :--- |
| 프로토콜 | Modbus RTU |
| 보드레이트 | 115200 bps (기본값, 코드에서 사용) — 9600/19200/38400/57600/128000 선택 가능 |
| 데이터 비트 | 8 |
| 패리티 | None |
| 스톱 비트 | 1 |
| 드라이버 주소 범위 | 1 ~ 127 (본 프로젝트: 드라이버#1=1, 드라이버#2=2) |
`xbox_motor_control.cpp``SerialPort::openPort()`가 termios로 위 설정(8N1, RAW 모드,
VTIME=1 → 0.1s read timeout)을 그대로 구성한다.
## 2.2 Modbus 프레임 포맷
```
[슬레이브 주소 1B][기능코드 1B][데이터 N B][CRC16 2B (Low,High 순)]
```
CRC는 표준 Modbus CRC16(다항식 0xA001, 초기값 0xFFFF) — `calcCRC16()`이 이를 그대로 구현.
### 지원 기능 코드
| 기능 | 코드 | 에러 응답 코드 |
| :--- | :-: | :-: |
| 다중 레지스터 읽기 | `0x03` | `0x83` |
| 단일 레지스터 쓰기 | `0x06` | `0x86` |
| 다중 레지스터 쓰기 | `0x10` | `0x90` |
| 에러 코드 | 이름 | 의미 |
| :-: | :--- | :--- |
| `0x01` | Illegal function code | 지원하지 않는 기능 코드 |
| `0x02` | Illegal data address | 잘못된 레지스터 주소 |
| `0x03` | Illegal data value | 잘못된 데이터 값 |
### 코드 구현 매핑
| 클래스 함수 | 기능 코드 | 용도 |
| :--- | :-: | :--- |
| `SerialPort::readRegs()` | 0x03 | 피드백/상태/전류한계 조회 |
| `SerialPort::writeReg()` | 0x06 | 제어워드/모드/브레이크 등 단일 레지스터 쓰기 |
| `SerialPort::writeRegs()` | 0x10 | 가감속시간, 목표속도(L/R 동시) 쓰기 |
응답 검증은 CRC16 재계산 + 슬레이브 주소 일치 여부로 이루어지며(`writeReg`/`writeRegs`/`readRegs`
모두 동일), 브로드캐스트(`slave==0`)일 때는 응답을 기다리지 않는다(`--bcast` 옵션).
읽기 타임아웃은 25ms(100Hz=10ms 주기 루프 안에서 여유를 둔 값), 쓰기 응답 타임아웃은 50ms로
설정되어 있다.
## 2.3 코드가 실제로 사용하는 레지스터 (초기화/제어/피드백 경로)
| 주소 | 이름 | 방향 | 코드 사용처 |
| :-- | :--- | :-: | :--- |
| `0x200D` | Control mode | W | `initDriver()`: `3` = 속도 제어 모드 고정 |
| `0x200E` | Control word | W | `initDriver()`: `0x0006` 알람 클리어 → `0x0007` Disable → (가감속 설정 후) `0x0008` Enable |
| `0x2080` / `0x2081` | 가속시간 좌/우 | W | `initDriver(acl_ms)``writeRegs(0x2080,{acl,acl})`로 2레지스터 동시 기록 (기본 150ms) |
| `0x2082` / `0x2083` | 감속시간 좌/우 | W | `initDriver(dcl_ms)``writeRegs(0x2082,{dcl,dcl})`로 동시 기록 (기본 150ms) |
| `0x2088` / `0x2089` | 목표속도 좌/우 (RPM, I16, ±3000) | W | `setRPMs()`: 매 틱 `writeRegs(0x2088,{l,r})`로 좌/우 동시 지령 |
| `0x201A` | 출력단자 B0 (좌 브레이크) | W | `setBrakes()`: `0`=해제, `1`=잠금 |
| `0x201B` | 출력단자 B1 (우 브레이크) | W | `setBrakes()`: 좌와 동일 값 동시 기록 |
| `0x2033` / `0x2034` | 좌 정격전류 / 좌 최대전류 (0.1A) | R/W | `readCurrentLimits()`로 조회, `setMaxCurrent()``0x2034`만 기록 |
| `0x2063` / `0x2064` | 우 정격전류 / 우 최대전류 (0.1A) | R/W | 좌와 동일 패턴 (우 채널) |
| `0x20A5` ~ `0x20AE` | 에러코드~토크 (아래 표 참고) | R | `readFeedback()`**10레지스터 연속 읽기**(0x03, count=10)로 한 번에 확보 |
### `readFeedback()`의 10레지스터 연속 읽기 상세 (`0x20A5` 시작, count=10)
| offset | 주소 | 이름 | 코드 내 변수 | 스케일 |
| :-: | :-- | :--- | :--- | :--- |
| 0 | `0x20A5` | 에러코드(좌) | `err_l` | 비트마스크 (그대로) |
| 1 | `0x20A6` | 에러코드(우) | `err_r` | 비트마스크 (그대로) |
| 2 | `0x20A7` | 실제위치 상위16비트(좌) | `val_l` 상위 | `int32_t` 합성 → `l_tick` |
| 3 | `0x20A8` | 실제위치 하위16비트(좌) | `val_l` 하위 | 〃 |
| 4 | `0x20A9` | 실제위치 상위16비트(우) | `val_r` 상위 | `int32_t` 합성 → `r_tick` |
| 5 | `0x20AA` | 실제위치 하위16비트(우) | `val_r` 하위 | 〃 |
| 6 | `0x20AB` | 실제속도(좌), 단위 0.1r/min | `vl` | `× 0.1f``l_fb` (RPM) |
| 7 | `0x20AC` | 실제속도(우), 단위 0.1r/min | `vr` | `× 0.1f``r_fb` (RPM) |
| 8 | `0x20AD` | 실제토크/전류(좌), 단위 0.1A | `tl` | `× 0.1f``l_torque_a` (A) |
| 9 | `0x20AE` | 실제토크/전류(우), 단위 0.1A | `tr` | `× 0.1f``r_torque_a` (A) |
이 한 번의 통신으로 에러코드·엔코더 위치(주행거리 계산용)·속도 피드백·전류(부하 진단용)를
모두 얻기 때문에, 100Hz 루프에서 드라이버 1대당 통신 1회(읽기)+1회(쓰기)만 필요하다.
2대 드라이버 × 2회 = 틱당 최대 4회 왕복, `comm_ms`로 HUD에 실측 소요시간을 표시한다.
> 위치(`0x20A7`~`0x20AA`)는 엔코더 라인수 1024 기준 4체배(quadrature)로 회전당
> `1024 × 4 = 4096`이 아니라, README 기준 실측 `16,384 ticks/rev`를 사용한다
> (ZLLG80ASM250-**4096**-B 모터명의 4096은 엔코더 라인수이며, 4체배 카운팅 시
> `4096 × 4 = 16384`가 된다. `0x2030`/`0x2060` "Encoder line" 파라미터 공장 기본값 1024와는
> 별개로, 실제 장착된 ZLLG80ASM250-4096-B 모터는 라인수 4096 사양이므로 드라이버의
> Encoder line 파라미터가 모터 사양(4096)에 맞춰 설정되어 있어야 `meters_per_tick` 계산이
> 정확하다. 드라이버 실측정격전류가 15A인 것도 이 모터의 정격과 일치.)
## 2.4 에러코드 (`0x20A5` / `0x20A6`)
| 값 | 의미 | 코드 내 한글 표기 |
| :-- | :--- | :--- |
| `0x0000` | 정상 (No error) | (없음) |
| `0x0001` | 과전압 (Over voltage) | 과전압 |
| `0x0002` | 저전압 (Under voltage) | 저전압 |
| `0x0004` | 과전류 (Over current) | 과전류 |
| `0x0008` | 과부하 (Over load) | 과부하 |
| `0x0010` | 전류 이상 (예약) | 전류이상(예약) |
| `0x0020` | 엔코더 오차 (Encoder out of tolerance) | 엔코더오차 |
| `0x0040` | 속도 이상 (예약) | 속도이상(예약) |
| `0x0080` | 기준전압 오류 (Reference voltage error) | 기준전압오류 |
| `0x0100` | EEPROM 오류 | EEPROM오류 |
| `0x0200` | 홀센서 오류 | 홀센서오류 |
| `0x0400` | 모터 과열 | 모터과열 |
| `0x0800` | 엔코더 오류 | 엔코더오류 |
| `0x2000` | 속도설정 오류 (지령속도가 정격속도 초과) | 속도설정오류 |
여러 비트가 동시에 서면 코드가 `"+"`로 이어붙여 표시한다 (`decodeDriverError()`).
드라이버 알람 상세 진단과 대응은 [05-error-troubleshooting.md](05-error-troubleshooting.md) 참고.
## 2.5 제어워드(`0x200E`)와 초기화 시퀀스
| 값 | 의미 |
| :-- | :--- |
| `0x05` | 비상 정지 (Emergency stop) |
| `0x06` | 알람 클리어 (Clear fault) |
| `0x07` | 정지 (Stop / Disable) |
| `0x08` | 활성화 (Enable) |
| `0x10` | 동기 시작 (Start Synchronous, 포지션 모드 전용) |
| `0x11` | 좌측 단독 시작 (Start Left, 포지션 모드) |
| `0x12` | 우측 단독 시작 (Start Right, 포지션 모드) |
`MotorDriver::initDriver()`의 실제 시퀀스 (각 단계 사이 30ms 대기):
1. `0x200E``0x0006` (알람 클리어)
2. `0x200E``0x0007` (Disable)
3. `0x200D``3` (속도 제어 모드)
4. `0x2080`/`0x2081` ← 가속시간 (좌우 동시, 기본 150ms)
5. `0x2082`/`0x2083` ← 감속시간 (좌우 동시, 기본 150ms)
6. `0x200E``0x0008` (Enable)
7. `setBrakes(true)` — 초기 상태는 브레이크 잠금
이 프로젝트는 속도 제어 모드(Profile Velocity Mode)만 사용하며, 포지션/토크 모드 관련 레지스터
(`0x208A`~`0x208F`, `0x2090`/`0x2091` 등)는 사용하지 않는다. 참고용으로 §2.7에 전체 표를 남긴다.
## 2.6 전류 한계 레지스터
| 주소 | 이름 | 단위 | 기본값 | 범위 |
| :-- | :--- | :-: | :-: | :--- |
| `0x2033` | 좌 정격전류 | 0.1A | 150 (15.0A) | 0-150 |
| `0x2034` | 좌 최대전류 | 0.1A | 300 (30.0A) | 0-300 |
| `0x2063` | 우 정격전류 | 0.1A | 150 (15.0A) | 0-150 |
| `0x2064` | 우 최대전류 | 0.1A | 300 (30.0A) | 0-300 |
`readCurrentLimits()`가 시작 시 1회 조회해 콘솔에 출력하고, `--max_current_a` 인자
(기본 20.0A)로 최대전류를 4바퀴 동일하게 낮춘다. 정격전류(150=15A)는 쓰지 않고 최대전류만
변경한다 — 근거와 튜닝 배경은 [05-error-troubleshooting.md](05-error-troubleshooting.md) 참고.
## 2.7 전체 레지스터 주소록 (Address Directory, 매뉴얼 §4 원문 기준)
### 공통 상수 (Left/Right 공통)
| 주소 | 이름 | 접근 | 기본값 | 설명 |
| :-- | :--- | :-: | :-: | :--- |
| `2000h` | Communication offline time | RW/S | 0 | ms, 0-32000. 시간 내 통신 없으면 정지 |
| `2001h` | RS485 Node ID | RW/S | 1 | 1-127 |
| `2002h` | RS485 Baud Rate | RW/S | 2 | 1:128000 2:115200 3:57600 4:38400 5:19200 6:9600 |
| `2003h` | Input signal status | RO | 0 | bit0-1: X0-X1 입력 레벨 |
| `2004h` | Output signal status | RO | 0 | bit0-1: Y0-Y1 출력 레벨 |
| `2005h` | Clear feedback position | RW | 0 | 0 무효, 1 좌, 2 우, 3 좌우 (미저장) |
| `2006h` | Reset absolute zero point | RW | 0 | 0 무효, 1 좌, 2 우, 3 좌우 (미저장) |
| `2007h` | Shaft state after power on | RW/S | 0 | 0 Enable 안 됨/락 안 됨, 1 Enable 안 됨/락 |
| `2008h` | Maximum motor speed | RW/S | 1000 | r/min, 1-1000 |
| `2009h` | Register parameter settings | RW | 0 | 0 무효, 1 공장 초기화 |
| `200Ah` | CAN Node ID | RW/S | 1 | 1-127 |
| `200Bh` | CAN Baud rate | RW/S | 1 | 0:1000 1:500 2:250 3:125 4:100 Kbit/s |
| `200Ch` | Parking mode | RW/S | 0 | 0 Close, 1 Open |
| `200Dh` | Control mode | RW/S | 0 | 0 undefined, 1 위치(상대), 2 위치(절대), 3 속도, 4 토크 |
| `200Eh` | Control word | RW | 0 | §2.5 참고 |
| `200Fh` | Sync/Async control status | RW/S | 0 | 0 동기, 1 비동기 |
| `2010h` | RW 레지스터 EEPROM 저장 여부 | RW | 0 | 0 무효, 1 저장 |
| `2011h` | Quick stop control | RW/S | 5 | 5 정지, 6 감속시간 적용 급정지, 7 감속시간 미적용 급정지 |
| `2012h` | Close operation control | RW/S | 1 | 0 무효, 1 정상정지 |
| `2013h` | Disable control | RW/S | 1 | 0 무효, 1 정지(switch on 상태로) |
| `2014h` | Halt control | RW/S | 1 | 1 정지, 2 감속 급정지, 3 무감속 급정지 |
| `2016h` | Input effective level | RW/S | 0 | bit0 X0, bit1 X1 (0 기본 High, 1 반전 Low) |
| `2017h` | Input X0 function | RW/S | 9 | 0 none, 1-8 NC, 9 비상정지 |
| `2018h` | Input X1 function | RW/S | 0 | 〃 |
| `2019h` | Output effective level | RW/S | 0 | bit0 Y0, bit1 Y1, bit2 B0, bit3 B1 |
| `201Ah` | **출력 B0 (좌 브레이크)** | RW/S | 0 | 0 Open, 1 Close — **코드 사용** |
| `201Bh` | **출력 B1 (우 브레이크)** | RW/S | 0 | 0 Open, 1 Close — **코드 사용** |
| `201Ch` | Output Y0 function | RW/S | 0 | 0 undefined, 1 알람신호, 2 상태신호, 3 목표위치도달(예약) |
| `201Dh` | Output Y1 function | RW/S | 0 | 〃 |
| `201Eh` | 드라이버 온도 보호 임계값 | RW/S | 800 | 0.1℃, 0-1200 |
| `201Fh` | Alarm PWM 처리 방식 | RW/S | 0 | 0 close, 1 open |
| `2020h` | Overload 처리 방식 | RW/S | 0 | 0 close, 1 open |
| `2021h` | I/O 비상정지 처리 모드 | RW/S | 0 | 0 축고정(우선I), 1 댐핑(우선II), 2 축해제(우선III); low8=INPUT1 high8=INPUT2 |
| `2022h` | Given speed resolution | RW/S | 1 | 1:1RPM ... A:0.1RPM |
| `2023h` | Velocity overshoot | RW/S | 1 | 0 close, 1 open |
| `2024h` | Regen resistance value | RW/S | 50 | 0.1Ω, 0-1000 |
| `2025h` | Regen resistance power | RW/S | 100 | W, 0-1000 |
| `2026h` | Regen opening voltage | RW/S | 650 | 0.1V, 360-750 |
| `2027h` | Regen close voltage | RW/S | 600 | 0.1V, 310-700 |
| `2028h` | Regen function control | RW/S | 1 | 0 close, 1 open |
| `2029h` | Default running direction | RW/S | 0 | 0 CW, 1 CCW |
### 좌모터 파라미터 (`2030h`~`204Ah`) / 우모터 파라미터 (`2060h`~`207Ah`, 좌+0x30 오프셋)
| 좌 주소 | 우 주소 | 이름 | 접근 | 기본값 | 설명 |
| :-- | :-- | :--- | :-: | :-: | :--- |
| `2030h` | `2060h` | Encoder line | RW/S | 1024 | 0-4096 |
| `2031h` | `2061h` | Hall offset angle | RW/S | 0 | 1°, -360~360 |
| `2032h` | `2062h` | Overload factor | RW/S | 200 | %, 0-300 |
| `2033h` | `2063h` | **정격전류** | RW/S | 150 | 0.1A, 0-150 — **코드 사용** |
| `2034h` | `2064h` | **최대전류** | RW/S | 300 | 0.1A, 0-300 — **코드 사용** |
| `2035h` | `2065h` | Overload protection time | RW/S | 300 | 10ms, 0-6553 |
| `2036h` | `2066h` | Position following error threshold | RW/S | 409 | 10counts, 1-6553 |
| `2037h` | `2067h` | Velocity smoothing factor | RW/S | 1000 | 0-30000 |
| `2038h` | `2068h` | Current Loop Kp | RW/S | 600 | 0-30000 |
| `2039h` | `2069h` | Current Loop Ki | RW/S | 300 | 0-30000 |
| `203Ah` | `206Ah` | Feedforward output smoothing | RW/S | 100 | 0-30000 |
| `203Bh` | `206Bh` | Torque output smoothing | RW/S | 100 | 0-30000 |
| `203Ch` | `206Ch` | Velocity Loop Kp | RW/S | 500 | 0-30000 |
| `203Dh` | `206Dh` | Velocity Loop Ki | RW/S | 100 | 0-30000 |
| `203Eh` | `206Eh` | Velocity Loop Kf | RW/S | 500 | 0-30000 |
| `203Fh` | `206Fh` | Position Loop Kp | RW/S | 100 | 0-30000 |
| `2040h` | `2070h` | Position Loop Kf | RW/S | 50 | 0-30000 |
| `2043h` | `2073h` | Initial velocity (속도모드) | RW/S | 1 | r/min, 1-250 |
| `2044h` | `2074h` | Initial velocity (위치모드) | RW/S | 1 | r/min, 1-250 |
| `2045h` | `2075h` | Motor poles | RW/S | 15 | 4-64 |
| `2046h` | `2076h` | Over temperature threshold | RW/S | 800 | 0.1℃, 0-1200 |
| `2047h`-`204Ah` | `2077h`-`207Ah` | Velocity observer coefficients 1-4 | RW/S | 1000/750/350/1000 | 0-30000 |
### 제어 파라미터 (`2080h`~`2091h`)
| 주소 | 이름 | 접근 | 기본값 | 설명 |
| :-- | :--- | :-: | :-: | :--- |
| `2080h` | **S자 가속시간(좌)** | RW/S | 500ms | 0-32767ms — **코드 사용 (150ms)** |
| `2081h` | **S자 가속시간(우)** | RW/S | 500ms | 0-32767ms — **코드 사용 (150ms)** |
| `2082h` | **S자 감속시간(좌)** | RW/S | 500ms | 0-32767ms — **코드 사용 (150ms)** |
| `2083h` | **S자 감속시간(우)** | RW/S | 500ms | 0-32767ms — **코드 사용 (150ms)** |
| `2084h` | 급정지 감속시간(좌) | RW/S | 10ms | 0-32767ms |
| `2085h` | 급정지 감속시간(우) | RW/S | 10ms | 0-32767ms |
| `2086h` | 토크 슬로프(좌) | RW/S | 300ms | mA/s(1/1000초 단위) |
| `2087h` | 토크 슬로프(우) | RW/S | 300ms | 〃 |
| `2088h` | **목표속도(좌)** | RW | 0 | I16, r/min, ±3000 — **코드 사용, 매 틱 기록** |
| `2089h` | **목표속도(우)** | RW | 0 | I16, r/min, ±3000 — **코드 사용, 매 틱 기록** |
| `208Ah`/`208Bh` | 목표위치 상/하위(좌) | RW | 0 | 포지션 모드 전용 (미사용) |
| `208Ch`/`208Dh` | 목표위치 상/하위(우) | RW | 0 | 포지션 모드 전용 (미사용) |
| `208Eh`/`208Fh` | 최대속도(좌/우, 포지션모드) | RW/S | 120 r/min | 포지션 모드 전용 (미사용) |
| `2090h`/`2091h` | 목표토크(좌/우) | RW | 0 | mA, ±30000 — 토크 모드 전용 (미사용) |
### 읽기 전용 상태 (`20A0h`~`20B0h`)
| 주소 | 이름 | 단위/범위 | 코드 사용 여부 |
| :-- | :--- | :--- | :--- |
| `20A0h` | Software version | - | 미사용 |
| `20A1h` | Bus voltage | 0.01V | 미사용 (배터리 전압 모니터링에 활용 가능) |
| `20A2h` | Status word | L:bit7-6, R:bit15-14 → 00 해제/40 축고정/80 비상정지/C0 알람; bit0(L)/bit8(R) 0정지 1구동 | 미사용 |
| `20A3h` | Hall input state | 0-7 (0,7이면 홀 오류), high8=좌 low8=우 | 미사용 |
| `20A4h` | Motor temperature | 1℃, -55~120, high8=좌 low8=우 | 미사용 (과열 사전감지에 활용 가능) |
| `20A5h` | **에러코드(좌)** | 비트마스크 (§2.4) | **코드 사용** |
| `20A6h` | **에러코드(우)** | 비트마스크 (§2.4) | **코드 사용** |
| `20A7h`/`20A8h` | **실제위치 상/하위(좌)** | counts, I16×2 | **코드 사용**`l_tick` |
| `20A9h`/`20AAh` | **실제위치 상/하위(우)** | counts, I16×2 | **코드 사용**`r_tick` |
| `20ABh` | **실제속도(좌)** | 0.1r/min | **코드 사용**`l_fb` |
| `20ACh` | **실제속도(우)** | 0.1r/min | **코드 사용**`r_fb` |
| `20ADh` | **실제토크/전류(좌)** | 0.1A, ±300 | **코드 사용**`l_torque_a` |
| `20AEh` | **실제토크/전류(우)** | 0.1A, ±300 | **코드 사용**`r_torque_a` |
| `20AFh` | Software connected status | `01` | 미사용 |
| `20B0h` | Driver temperature | 0.1℃, -550~1200 | 미사용 (과열 사전감지에 활용 가능) |
> **미사용이지만 유용한 레지스터**: `20A1h`(버스 전압), `20A4h`/`20B0h`(모터/드라이버 온도)는
> 현재 코드가 읽지 않는다. 배터리 저전압 경보나 열 관리 기능을 추가하려면 `readFeedback()`의
> 연속 읽기 범위를 확장하거나 별도 저빈도 폴링을 추가하는 것을 검토할 수 있다.
+110
View File
@@ -0,0 +1,110 @@
# 03. 로봇/드라이버 시스템 구성
출처: `README.md`, `xbox_motor_control.cpp` (main 함수 상단 파라미터), 실측값
## 3.1 로봇 물리 제원
| 항목 | 값 | 비고 |
| :--- | :-- | :--- |
| 구동 방식 | 스키드 스티어(Skid-Steer) 4WD | 좌/우 각 2륜씩 기계적으로 독립 구동, 조향 없음 |
| 전폭 (Track Width, W) | 576.0 mm | 좌우 바퀴 중심 간 거리 |
| 전장 축간거리 (Wheelbase, L) | 368.4 mm | 전후 바퀴 중심 간 거리 |
| 휠 유효 반경 (R) | 131.517 mm | 버니어 실측값, 10.35인치 타이어 |
| 전면 범퍼 오프셋 | 45.0 mm | 휠 축 → 로봇 최전방 범퍼 |
| 로봇 전폭(차체) | 410 mm | 라이다 회피 폭 계산 기준 |
| 로봇 전장(차체) | 631 mm | |
| 로봇 전고 | 286 mm | |
## 3.2 모터 & 드라이버 매핑
| 위치 | 모터 | 소속 드라이버 | Station ID | 시리얼 포트 | 드라이버 채널 |
| :--- | :--- | :-: | :-: | :--- | :-: |
| 전방 좌 (FL) | ZLLG80ASM250-4096-B V2.12 | 드라이버 #1 (전방) | 1 | `/dev/ttyUSB0` | Left |
| 전방 우 (FR) | ZLLG80ASM250-4096-B V2.12 | 드라이버 #1 (전방) | 1 | `/dev/ttyUSB0` | Right |
| 후방 좌 (RL) | ZLLG80ASM250-4096-B V2.12 | 드라이버 #2 (후방) | 2 | `/dev/ttyUSB0` (동일 포트, 데이지체인) 또는 `--port2` | Left |
| 후방 우 (RR) | ZLLG80ASM250-4096-B V2.12 | 드라이버 #2 (후방) | 2 | 〃 | Right |
* 드라이버 2대는 **동일 RS485 버스(같은 USB-RS485 포트)를 공유**하는 것이 기본 동작이며,
Modbus 슬레이브 주소(1, 2)로 구분한다. `--port2`를 지정하면 별도의 물리 포트를 사용하도록
전환 가능(`sp2_ptr``&sp1` 대신 `&sp2`를 가리키게 됨).
* `--bcast`/`--broadcast` 옵션 사용 시 두 드라이버 모두 주소 `0`(브로드캐스트)으로 동시에
명령을 받는다 — 이 경우 개별 피드백 조회/전류 조회는 생략된다(`driver_rear_ptr = nullptr`
로 유지, 앞뒤 드라이버가 응답을 구분해 회신할 수 없기 때문).
* 좌/우 부호가 반대인 이유: `MotorDriver::setRPMs(l_rpm, r_rpm)`에 넘기는 지령에서
`target_fr = -v_r * rpm_per_ms`, `target_rr = -v_r * rpm_per_ms`처럼 **우측 바퀴는 항상
부호 반전**해서 넘긴다. 좌/우 모터가 로봇 상에서 물리적으로 반대 방향으로 장착되어 있어
(양쪽 모터가 "전진"으로 회전해야 하는 U/V/W 위상이 거울 대칭) 이렇게 보정하지 않으면
우측 바퀴가 반대로 회전한다.
## 3.3 엔코더 해상도
* 모터 1회전당 **16,384 ticks** (4체배 카운팅 기준)
* `meters_per_tick = (2π × wheel_radius) / 16384`
* 주행거리(Odometry)는 4륜 엔코더 델타의 **중앙값(4개 중 중간 2개 평균)**으로 계산해,
웅덩이 등으로 한 바퀴가 들떠 헛도는 경우의 이상치를 자동으로 배제한다 (`dist_axle`).
`dist_bumper = dist_axle + bumper_offset`(전진 시)으로 범퍼 기준 거리도 함께 산출.
## 3.4 스키드 스티어 운동학
### 유효 선회 반경 계수
```cpp
effective_w = sqrt(track_width² + wheelbase²) // 대각선 유효 폭
k_skid = (track_width + wheelbase² / track_width) / 2
```
`k_skid`는 순수 사각 스키드 스티어(회전축이 로봇 중심)보다 실제 마찰 중심을 반영해 보정한
계수로, 직진+커브 선회 시 좌/우 바퀴 속도 차를 계산하는 데 사용된다.
### 모드 1 — 직진/커브 선회 (`is_spin_turn == false`)
좌측 스틱 전후(ly)로 전진 속도 `v_x = ly × max_v`를 만들고, 좌측 스틱 좌우(lx)로 곡선 조향을
만든다. 저속에서 커브 반경이 스로틀과 무관하게 일정하도록, 각속도 `omega`는 **현재 전진 속도
`v_x`에 비례**하도록 계산된다(상수 `max_v`가 아님 — 저속에서 안쪽 바퀴가 역회전해버리는 버그를
막기 위한 수정):
```cpp
omega += -lx * (abs(v_x) / (effective_w / 2))
v_l = v_x - omega * k_skid
v_r = v_x + omega * k_skid
target_fl = target_rl = v_l * rpm_per_ms
target_fr = target_rr = -v_r * rpm_per_ms // 우측 부호 반전
```
### 모드 2 — 제자리 회전 (`is_spin_turn == true`, ly==0 && rx!=0)
우측 스틱 좌우(rx)만으로 제자리 선회한다. 순수 제자리 회전(ICR이 로봇 중심)은 고마찰 노면에서
정지마찰이 급격히 파괴돼 바퀴가 긁히므로, **미세한 전진 성분(`spin_arc_bias`, 기본 12%)**을
섞어 ICR을 로봇 중심에서 살짝 벗어나게 해 스크럽 마찰을 줄인다:
```cpp
omega = -rx * (max_spin_v / (effective_w / 2))
v_bias = abs(rx) * max_spin_v * spin_arc_bias
v_l = v_bias - omega * (effective_w / 2)
v_r = v_bias + omega * (effective_w / 2)
```
제자리 회전 진입 조건은 `ly == 0.0f`의 **완전 일치 비교**다. `applyDeadzone()`이 데드존
이하 입력을 정확히 `0.0f`로 반환하기 때문에 안전하며, 부동소수점 근사 오차로 인한 오탐을
막기 위해 별도 임계값을 두지 않고 정확히 이 특성에 의존한다.
### 라이다 자동 회피 조향 (측면 회피 시, 조이스틱 좌우 입력이 없을 때)
```cpp
v_l = v_x - omega * k_skid
v_r = v_x + omega * k_skid
```
(제자리 회전이 아니면서 `lx==rx==0`이고 `omega!=0`인 경우 — [04-software-control.md](04-software-control.md)
의 라이다 회피 절 참고)
## 3.5 속도 → 드라이버 RPM 지령 변환
```cpp
rpm_per_ms = 60 / (2π × wheel_radius) // m/s → RPM 변환 계수
```
계산된 `target_*`(RPM)는 곧바로 드라이버에 보내지 않고, 저크 제한(Jerk-Limited) S-curve
프로파일(`jerkLimitedStep()`)을 거쳐 `cmd_*`로 서서히 수렴한 뒤 `setRPMs()`로 전달된다.
소프트웨어 측 가감속/저크 프로파일은 드라이버 자체의 가감속 레지스터(`0x2080`~`0x2083`,
150ms)보다 상위 레벨에서 한 번 더 부드럽게 다듬는 이중 구조다. 상세는
[04-software-control.md](04-software-control.md) 참고.
+194
View File
@@ -0,0 +1,194 @@
# 04. 소프트웨어 제어 구조 (`xbox_motor_control.cpp`)
출처: `xbox_motor_control.cpp` 전체 (1382줄), `Makefile`, `run_4wd.sh`
## 4.1 빌드 & 실행
```bash
make # g++ -O3 -std=c++17 -Wall -Wextra ... -lSDL2 -lpthread -llivox_lidar_sdk_shared
./run_4wd.sh [PORT] # USB latency_timer 1ms 튜닝 + 빌드(필요시) + 실행
```
`run_4wd.sh`는 실행 전 `set_low_latency.sh``/sys/bus/usb-serial/devices/<dev>/latency_timer`
16ms(리눅스 기본값) → 1ms로 낮춰, 100Hz 제어 루프에서 시리얼 read 지연이 병목이 되지 않게 한다.
## 4.2 클래스 구조
```
SerialPort — 포트 open/close, Modbus RTU read/write (writeReg/writeRegs/readRegs)
MotorDriver — SerialPort + slave_id를 감싸는 드라이버 1대(좌우 2채널) 추상화
initDriver / setBrakes / setRPMs / readFeedback /
readCurrentLimits / setMaxCurrent
LidarObstacleDetector — Livox Mid-360S SDK 콜백 기반 장애물 거리 추적 (전/후/좌/우 4방향)
```
드라이버 2대(`driver_front`, `driver_rear`)가 각각 `MotorDriver` 인스턴스로 생성되며, 브로드캐스트
모드가 아니면 후방 드라이버 포인터(`driver_rear_ptr`)가 유효해 개별 제어/피드백이 가능하다.
## 4.3 100Hz 메인 루프 개요
```
매 틱 (10ms):
1. SDL 이벤트 처리 (버튼: A=트립리셋, X=라이다 토글, B/Back=종료)
2. 조이스틱 입력 읽기 + 데드존 적용
3. 라이다 장애물 회피 로직 (속도 스케일링 + 조향 오프셋)
4. 스키드 스티어 운동학으로 target_fl/fr/rl/rr(RPM) 계산
5. airborne(들뜸) 판정된 바퀴는 target를 0으로 강제
6. 상태머신(STOPPED/RUNNING/STOPPING) + 저크 제한 S-curve로 cmd_* 갱신
7. setRPMs()로 드라이버에 지령 전송
8. readFeedback()으로 드라이버에서 피드백 수신
9. 드라이버 알람(에러코드) 감지 및 콘솔 경고
10. 휠 상태(정상/들뜸/걸림) 판정, 주행거리 갱신
11. (옵션) CSV 로그 기록
12. HUD 한 줄 출력 (\r로 갱신)
```
## 4.4 상태 머신
| 상태 | 전이 조건 | 동작 |
| :--- | :--- | :--- |
| `STOPPED` | 초기 상태 | `target_*` 절대값이 0.1 RPM 초과하면 브레이크 해제(`setBrakes(false)`) 후 50ms 대기, `RUNNING`으로 전이 |
| `RUNNING` | `STOPPED`에서 전이 | 저크 제한 프로파일로 `cmd_*``target_*` 추종. `target_*``cmd_*`가 모두 임계값 이하면 `STOPPING`으로 전이 |
| `STOPPING` | `RUNNING`에서 전이 | 감속 프로파일로 0으로 수렴. `cmd_fl/fr`이 0.1 RPM 미만이면 즉시 정지 지령(`setRPMs(0,0)`) 후 50ms 뒤 브레이크 잠금, `STOPPED`로 복귀 |
브레이크 해제/잠금 시 50ms 대기를 두는 이유는 전자식 브레이크(솔레노이드)의 기계적 응답 시간을
확보하기 위함이다.
## 4.5 저크 제한(Jerk-Limited) S-curve 프로파일
```cpp
jerkLimitedStep(current_vel, target_vel, current_accel&, max_accel, max_decel, jerk_limit)
```
* 목표 속도까지 **가속도 자체를 매 틱 `jerk_limit × dt`만큼만 변화**시켜 부드럽게 목표 가속도에
도달시킨다. 정지 상태에서 출발할 때 첫 틱부터 풀가속도가 걸려 정지마찰이 급격히 파괴되는
문제(예: 저마찰 노면에서 순간 슬립)를 막기 위한 구조.
* 오버슈트 방지: 다음 틱 속도가 목표를 넘어서면 그 자리에서 목표값으로 스냅하고 가속도를 0으로
리셋한다.
* 가속/감속/저크 한계값은 CLI 인자로 조정 가능 (§4.8).
* 제자리 회전은 `spin_jerk_rate`(기본 220 RPM/s²)로 직진/커브 선회(`jerk_rate`, 기본 600 RPM/s²)
보다 훨씬 완만하게 적용된다 — 고마찰 노면에서 제자리 선회 시 정지마찰이 급격히 깨지지 않도록
가속에 도달하는 시간을 더 길게 늘린 것.
## 4.6 착지(Landing) 스냅 처리
들뜸(airborne) 상태에서 해제된(착지) 직후 한 틱은, 저크 제한 프로파일을 건너뛰고 `cmd_*`
`target_*`로 즉시 스냅한다(`just_landed_*` 플래그). 차체는 이미 그 속도로 이동 중인데 지령이
정지출발용 완만한 가속 램프를 다시 타면, 그 사이 바퀴가 지면 이동 속도를 못 따라가며 끌리는
현상을 없애기 위함.
## 4.7 라이다(Livox Mid-360S) 장애물 회피
`LidarObstacleDetector`가 포인트클라우드 콜백에서 좌표를 17° 하향 피치 보정 후 4개 구역
(전방/후방/좌측/우측)의 최소 거리를 EMA(지수이동평균, 접근 시 즉각 반응·회복 시 완만)로 추적한다.
| 파라미터 | 기본값 | 의미 |
| :--- | :-: | :--- |
| `front_stop_dist` | 0.35 m | 전방 완전 정지 |
| `front_warn_dist` | 0.50 m | 전방 감속 시작 (선형 스케일) |
| `side_dodge_dist` | 0.50 m | 측면 회피 조향 시작 |
| `side_stop_dist` | 0.20 m | 측면 회피 강도 100% 도달 거리 |
| `max_dodge_omega` | 0.35 rad/s | 자동 회피 최대 조향 각속도 |
* 전진 중 전방 장애물 → 속도 스케일링(0~1) 또는 완전 정지.
* 후진 중 후방 장애물 → 완전 정지만 적용(감속 스케일링 없음).
* 전진 중이면서 사용자가 좌우 조향(rx) 입력이 없을 때만 측면 회피 조향 개입 (요청 사양: 후진 시
측면 회피 미적용).
* 라이다 데이터 800ms 이상 미수신 시 `connected=false`로 판단, HUD에 `LIDAR:WAITING` 표시.
* X 버튼으로 라이다 회피 기능 자체를 런타임에 토글 가능.
## 4.8 휠 이상(들뜸/걸림) 진단
속도 폐루프 특성상(부하 무관하게 지령 RPM을 추종하려 함) "지령 대비 실제속도" 하나만으로는
무부하(들뜸)를 판정할 수 없다. 두 개의 독립된 판정 축을 사용한다:
### 걸림/과부하 판정 (`classifyStall`)
```cpp
vel_ratio = |RPM| / |RPM|
stall = (vel_ratio < stall_vel_ratio) && (|| >= stall_current_a)
```
지령속도가 `min_active_rpm`(기본 3 RPM) 미만이면 판정 보류. 기본값: `stall_vel_ratio=0.5`,
`stall_current_a=12.0A`(정격 15A 근접).
### 무부하/들뜸 판정 (`classifyAirPair`)
같은 지령속도를 받는 **같은쪽 앞뒤 페어**(FL↔RL, FR↔RR)의 전류를 비교한다. 좌/우를 통째로
비교하면 정상 커브 선회에서 좌우 지령속도가 원래 다르기 때문에 오탐이 발생하므로, 반드시
`target_fl==target_rl`, `target_fr==target_rr`가 항상 성립하는 앞뒤 페어끼리만 비교한다.
```cpp
hi = max(|amp_a|, |amp_b|)
if (hi < 0.5A) return; // 둘 다 무전류(관성 주행)면 판정 보류
if (a < hi * airborne_current_ratio) air_a = true; // 기본 0.35
if (b < hi * airborne_current_ratio) air_b = true;
```
**디바운스**: 노이즈성 순간 전류 편차로 즉시 개입하지 않도록, `airborne_debounce_ticks`
(기본 5틱=50ms) 연속으로 조건이 유지될 때만 실제 `airborne_*` 상태가 확정되고 해당 바퀴
`target`이 0으로 강제된다. 접지력이 회복돼 전류가 정상화되면 다음 판정 틱에서 자동 해제되고,
§4.6의 착지 스냅 로직으로 즉시 원래 지령을 재개한다.
HUD의 `WHL(FL/FR/RL/RR)` 4글자는 각 바퀴 상태를 `O`(정상)/`A`(들뜸,Airborne)/`S`(걸림,Stall)로 표시.
## 4.9 드라이버 알람 감지
`readFeedback()`로 받은 `err_f_l/err_f_r/err_r_l/err_r_r` 중 하나라도 0이 아니면 알람 발생으로
간주, **상태 전이 시(0→알람) 1회만** 콘솔에 `decodeDriverError()` 결과와 함께 큰 경고를 출력한다
(매 틱 갱신되는 HUD 줄과 별개 라인). 알람 발생 시 드라이버가 명령을 무시하는 잠금 상태가 되어
소프트웨어 재시작만으로는 해제되지 않는 경우가 많음을 코드 주석이 명시 — 하드웨어 알람 클리어
또는 전원 재투입이 필요할 수 있다. 상세 대응은
[05-error-troubleshooting.md](05-error-troubleshooting.md) 참고.
## 4.10 CSV 로깅 (`--log <경로>`)
매 틱 아래 컬럼을 기록한다:
```
t_ms,state,ly,lx,rx,
cmd_fl,cmd_fr,cmd_rl,cmd_rr,
fb_fl,fb_fr,fb_rl,fb_rr,
amp_fl,amp_fr,amp_rl,amp_rr,
stat_fl,stat_fr,stat_rl,stat_rr,
err_fl,err_fr,err_rl,err_rr,
dist_axle,comm_ms
```
실외에서 로봇을 조종하며 화면을 동시에 읽기 어려우므로, 문제가 된 구간(턱 넘는 지점 등)을
나중에 잘라서 분석하기 위한 용도.
## 4.11 CLI 인자 전체 목록
| 인자 | 기본값 | 설명 |
| :--- | :-- | :--- |
| `--port`, `--port1` | `/dev/ttyUSB0` | 전방 드라이버(및 기본 후방) 시리얼 포트 |
| `--port2` | (없음, port1 재사용) | 후방 드라이버 전용 포트 지정 시 |
| `--id1` | 1 | 전방 드라이버 Modbus 슬레이브 주소 |
| `--id2` | 2 | 후방 드라이버 Modbus 슬레이브 주소 |
| `--bcast` / `--broadcast` | off | 두 드라이버 모두 주소 0(브로드캐스트)으로 동시 제어 |
| `--track_width` | 0.576 m | 좌우 바퀴 중심거리 |
| `--wheelbase` | 0.3684 m | 전후 바퀴 중심거리 |
| `--radius` | 0.131517 m | 휠 유효 반경 |
| `--bumper` | 0.045 m | 휠 축→전면 범퍼 거리 |
| `--accel` | 120 RPM/s | 최대 가속도 |
| `--decel` | 180 RPM/s | 최대 감속도 |
| `--jerk` | 600 RPM/s² | 직진/커브 저크 한계 |
| `--spin_jerk` | 220 RPM/s² | 제자리 회전 저크 한계 |
| `--spin_arc_bias` | 0.12 | 제자리 회전 시 혼합할 미세 전진 비율 (0~1) |
| `--min_active_rpm` | 3.0 RPM | 이상판정 보류 임계 지령속도 |
| `--airborne_current_ratio` | 0.35 | 무부하(들뜸) 판정 전류비 |
| `--stall_vel_ratio` | 0.5 | 걸림 판정 속도비 |
| `--stall_current_a` | 12.0 A | 걸림 판정 전류 임계값 |
| `--airborne_debounce_ticks` | 5 (50ms) | 들뜸 판정 디바운스 |
| `--log <path>` | (비활성) | CSV 로그 파일 경로 |
| `--max_current_a` | 20.0 A | 4바퀴 최대전류 일괄 설정 (음수=미변경) |
| `--no_lidar` | (라이다 사용) | 라이다 비활성화 |
| `--lidar_config` | `mid360s_config.json` | Livox SDK 설정 파일 |
| `--front_stop` | 0.35 m | 전방 정지거리 |
| `--front_warn` | 0.50 m | 전방 경고(감속)거리 |
| `--side_dodge` | 0.50 m | 측면 회피 감지거리 |
| `--robot_width` | 0.410 m | 라이다 회피 폭 계산용 로봇 전폭 |
| `--lidar_height` | 0.460 m | 라이다 설치 높이 |
| `--lidar_pitch` | 17.0 deg | 라이다 하향 피치각 |
| `--lidar_x_offset` | 0.250 m | 라이다 전방 오프셋 |
| `--min_z` / `--max_z` | -0.40 / 0.80 m | 라이다 필터링 Z축 범위 |
+91
View File
@@ -0,0 +1,91 @@
# 05. 알람 진단 & 트러블슈팅
출처: `xbox_motor_control.cpp` 주석/구현, `resource/ZLAC8015D V4 Series RS485 Communication*.pdf`,
`resource/ZLAC8015D V4 Series Manual*.pdf`
## 5.1 드라이버 알람(에러코드) 대응표
콘솔에 `[!!! 드라이버 알람 발생 !!!]`이 출력되면 `0x20A5`(좌)/`0x20A6`(우) 값을 근거로
`decodeDriverError()`가 원인을 표시한다. HUD의 `[!!알람!!]` 태그도 `fault_active` 동안 계속
표시된다.
| 비트 | 한글 표기 | 가능한 원인 | 권장 대응 |
| :-- | :--- | :--- | :--- |
| `0x0001` 과전압 | 과전압 | 배터리 전압이 75V 근접/초과, 급감속 시 회생 전류 유입 | 배터리 전압 확인, 회생 저항(`RES` 단자) 장착 검토, `2011h` 급정지 설정을 감속시간 포함(6) 방식으로 |
| `0x0002` 저전압 | 저전압 | 배터리 방전, 배선 전압강하, 순간 대전류로 전압 새그 | 배터리 충전 상태·배선 굵기 확인, `--max_current_a`로 최대전류를 낮춰 순간 전압강하 완화 |
| `0x0004` 과전류 | 과전류 | 상간 단락, 급격한 부하 변화, 배선 손상 | 배선/커넥터 점검, 모터 상간 저항 측정. 지속 시 드라이버 교체 검토 |
| `0x0008` 과부하 | 과부하 | 지형 저항/걸림 상태가 `2035h`(과부하 보호시간, 기본 300×10ms=3s) 이상 지속 | §5.3 전류 한계 튜닝 참고. 걸림(Stall) HUD 표시와 함께 발생하면 지형/장애물 확인 |
| `0x0020` 엔코더오차 | 엔코더오차 | 위치 폐루프 추종 오차가 `2036h`(기본 4090 counts) 초과 | 급격한 외력(충돌, 헛도는 지면)으로 순간 발생 가능. 반복 시 엔코더 배선 확인 |
| `0x0080` 기준전압오류 | 기준전압오류 | 드라이버 내부 기준전압 회로 이상 | 드라이버 하드웨어 이상 — 제조사 문의 |
| `0x0100` EEPROM오류 | EEPROM오류 | 파라미터 저장영역 읽기 실패 | `2009h=1`로 공장 초기화 후 필요한 파라미터(전류한계, 가감속 등) 재설정 |
| `0x0200` 홀센서오류 | 홀센서오류 | 홀 케이블 미접속/단선, 핀 순서 오류 | J2/J6 커넥터 및 홀 배선 점검 |
| `0x0400` 모터과열 | 모터과열 | 모터 온도가 임계치 초과(과부하 지속 주행 등) | 잠시 정지해 냉각, 반복 시 `--max_current_a` 하향 또는 부하 조건 재검토 |
| `0x0800` 엔코더오류 | 엔코더오류 | 엔코더 케이블 헐거움/핀 순서 오류(매뉴얼 명시) | J2/J6 커넥터 재체결, 케이블 단선 여부 확인 |
| `0x2000` 속도설정오류 | 속도설정오류 | 지령속도가 정격 속도(`2008h`, 기본 1000rpm) 초과 | 코드의 `setRPMs()` 클램프(±3000)가 드라이버 정격보다 커서 이론상 발생 가능 — `2008h` 값과 실제 사용 RPM 범위 확인 |
> `0x0010`(전류이상), `0x0040`(속도이상)은 매뉴얼상 예약(Reserved) 비트로 정상 동작에서는
> 발생하지 않는다.
**공통 대응 원칙**: 알람 발생 시 드라이버가 명령을 무시하는 잠금 상태가 될 수 있어 소프트웨어
재시작만으로는 풀리지 않는 경우가 많다. RS485로 `0x200E ← 0x0006`(알람 클리어) 후
`0x200E ← 0x0008`(Enable) 순으로 재초기화가 필요하며, `initDriver()`가 프로그램 재시작 시
이 시퀀스를 자동 수행한다. 그래도 반복되면 전원 재투입(Power Cycle)을 시도한다.
## 5.2 휠 이상(들뜸/걸림) 진단 — 알람이 아닌 소프트웨어 레벨 진단
드라이버 알람과 별개로, 애플리케이션 레벨에서 4륜 각각의 상태를 실시간 분류한다
(원리는 [04-software-control.md](04-software-control.md) §4.8 참고). HUD `WHL` 표시:
| 표시 | 의미 | 조치 |
| :-: | :--- | :--- |
| `O` | 정상(Ok) | - |
| `A` | 들뜸(Airborne) — 동료 바퀴 대비 전류가 비정상적으로 낮음 (무부하 헛돎) | 자동으로 해당 바퀴 지령을 0으로 낮춤. 지속되면 지형(웅덩이, 턱) 확인 |
| `S` | 걸림(Stall) — 지령 대비 실제속도가 크게 못 따라가며 고전류 지속 | 장애물 끼임, 지형 저항 확인. 반복 시 `0x0008` 과부하 알람으로 이어질 수 있음 |
이 진단은 어디까지나 소프트웨어 추정치이며, 드라이버 알람(§5.1)과는 독립적으로 동작한다.
`--log`로 CSV를 남기면 `stat_*`/`amp_*` 컬럼으로 사후 분석이 가능하다.
## 5.3 최대전류(`0x2034`/`0x2064`) 튜닝 배경
코드 기본값은 `--max_current_a 20.0`(공장 기본 최대전류 30A보다 낮춤). 코드 주석에 남겨진
실측 배경:
> 실측 결과 정격 15A / 최대 30A(공장 기본값) 그대로 사용 시, 걸림/과부하 상황에서 드라이버가
> 30A까지 전류를 밀어붙이며 3초(과부하 보호시간 `2035h` 기본값)씩 버티다가 과부하 알람이
> 발생하는 문제가 있었다. 정격(15A) 위로 5A 정도의 여유는 남기되, 상한을 20A로 낮춰 과도한
> 전류로 장시간 버티는 상황 자체를 줄인 것.
* `--max_current_a`에 음수를 주면 드라이버에 저장된 기존/공장 설정을 그대로 둔다(미변경).
* 정격전류(`0x2033`/`0x2063`)는 코드가 변경하지 않는다 — 최대전류만 낮춘다.
* 실외 험지 주행에서 순간 고부하가 잦다면 이 값을 상황에 맞게 재조정할 것. 너무 낮추면
정상 등판/가속 상황에서도 전류 제한에 걸려 토크 부족이 발생할 수 있다.
* 시작 시 콘솔에 출력되는 "전방/후방 드라이버 전류설정" 로그로 **실제 드라이버에 저장된 값**을
항상 확인할 수 있다(문서 기본값이 아니라 하드웨어에 실제로 쓰여있는 값).
## 5.4 RS485 통신 트러블슈팅
| 증상 | 가능한 원인 | 확인/조치 |
| :--- | :--- | :--- |
| 시작 시 "포트 열기 실패" | 잘못된 `/dev/ttyUSB*` 경로, 권한 부족 | `ls /dev/ttyUSB*`로 실제 장치명 확인, `dialout` 그룹 권한 확인 |
| "전류설정 조회 실패" 경고 | 배선 불량, 종단저항 미설정, 슬레이브 주소 불일치 | J5 커넥터 A/B/SGND 배선, 데이지체인 마지막 드라이버의 SW2 종단저항, `--id1`/`--id2`가 실제 드라이버 주소와 일치하는지 확인 |
| 명령은 가는데 응답이 자주 누락(HUD `comm_ms` 급등, 피드백 값이 갱신 안 됨) | 100Hz 루프 대비 응답 지연/노이즈, USB latency_timer 미적용 | `set_low_latency.sh` 적용 여부 확인(`cat /sys/bus/usb-serial/devices/<dev>/latency_timer`가 1이어야 함), 차폐 트위스트페어 케이블 및 접지 확인 |
| 알람은 없는데 바퀴가 반대로 회전 | 좌/우 모터 배선 반대 장착 오인, `2029h` 기본 회전방향 설정 문제 | [03-system-architecture.md](03-system-architecture.md) §3.2의 우측 부호 반전 로직 확인. 하드웨어적으로 U/V/W가 뒤바뀐 경우 `2029h`(Default running direction)로도 보정 가능 |
| CRC 불일치로 통신 실패 반환 (`readRegs`/`writeReg``false`) | 전기 노이즈, 접지 불량, 데이지체인 배선 길이 과다 | 코드는 CRC16을 재계산해 손상된 응답을 자동으로 폐기하므로 오탐 지령은 방지되지만, 값 자체가 갱신되지 않아 HUD가 멈춘 것처럼 보일 수 있다. 배선 점검 필요 |
> 코드는 통신 실패 시 **재시도(retry)를 하지 않는다** — 실패한 틱은 그냥 다음 100Hz 틱으로
> 넘어간다. 이는 저지연(latency)을 우선한 설계 선택이며, 지속적인 통신 불량은 알람이 아니라
> "피드백이 갱신되지 않음"으로만 나타날 수 있으므로 HUD의 `comm_ms` 값과 위치/전류 피드백이
> 실제로 변하는지 함께 관찰해야 한다.
## 5.5 운용 전 체크리스트
1. 배선: 좌/우 U/V/W, 엔코더/홀(J2·J6), RS485 A/B/SGND(J5), 브레이크(J3) 재확인.
2. RS485 종단저항: 데이지체인 마지막 드라이버에서만 SW2 ON.
3. `run_4wd.sh` 실행 후 콘솔에서 "전방/후방 드라이버 전류설정" 값이 기대값(정격15A 근방,
최대 20~30A)인지 확인.
4. 알람 없이 브레이크가 해제/잠금되는지(휠 수동 회전 저항으로 체감) 확인.
5. 저속으로 A버튼(트립리셋) → 직진 → 정지 → 후진 테스트로 좌/우 회전 방향이 의도대로인지
확인.
6. 라이다 연결 시 HUD에 `LIDAR:OFF`가 아닌 실측 거리(`F:x.xxm|L:...|R:...`)가 표시되는지 확인.
7. 실외 주행 전 `--log`로 CSV 기록을 켜서 최초 주행 구간을 남겨두면 사후 튜닝(§5.3, §4.11의
`--stall_*`/`--airborne_*` 인자)에 유용하다.
+162
View File
@@ -0,0 +1,162 @@
# 06. IMU 도입을 통한 제어 고도화 — 조사 및 계획 (실행 전 검토용)
> **이 문서는 계획안이다. 코드는 아직 수정하지 않았다.** 아래 내용을 검토하고 방향에 동의하면
> 그때 `xbox_motor_control.cpp`에 반영을 시작한다.
>
> **갱신 이력**: 이 문서에 대한 1차 Q&A가 [`QnA/q1.md`](../QnA/q1.md) / [`QnA/a1.md`](../QnA/a1.md)에
> 정리되어 있다. 개루프/폐루프 계층 구조에 대한 실무 관행, 온라인 ICR 추정 가능성, 헤딩 홀드에
> 필요한 필터링 수준, IMU 하드웨어(HWT905) 확정 내용이 아래 §6.4, §6.7에 반영되어 있다.
출처: `resource/husky.md`(Clearpath Husky User Manual 링크), Clearpath `robot_localization`
연동 자료, Yi, J. et al., *"Kinematic Modeling and Analysis of Skid-Steered Mobile Robots With
Applications to Low-Cost Inertial-Measurement-Unit-Based Motion Estimation,"* IEEE Transactions
on Robotics, 25(5), 2009 (이하 "Yi 2009"), Pentzer, J. et al., *"Model-based Prediction of
Skid-steer Robot Kinematics Using Online Estimation of Track Instantaneous Centers of Rotation,"*
Journal of Field Robotics, 2014 (이하 "Pentzer 2014"), 그리고 본 저장소
`xbox_motor_control.cpp` 현재 구현.
## 6.1 왜 이 조사를 하는가
Husky(Clearpath Robotics)는 본 로봇과 동일한 **스키드 스티어 4WD 실외 플랫폼**이다. Husky의
표준 소프트웨어 스택은 휠 오도메트리만 쓰지 않고, `robot_localization`
`ekf_localization_node`로 **휠 오도메트리 + IMU**를 융합해 `/odometry/filtered`를 만들고, 이를
헤딩 유지·자율주행의 기준으로 삼는다. 이 구조와 그 이론적 근거(Yi 2009)를 우리 시스템과
비교하면, 현재 우리 제어가 왜 "투박"한지, 그리고 IMU 추가가 실제로 어떤 문제를 해결하는지가
명확해진다.
## 6.2 현재 구현의 한계 (코드 근거)
| # | 한계 | 코드 근거 |
| :-: | :--- | :--- |
| 1 | **완전 개루프(open-loop) 텔레옵.** 조이스틱 입력 → 킨매틱스 수식 → 저크제한 램프 → RPM 지령까지, 로봇이 실제로 그 지령대로 움직였는지 확인하는 피드백이 전무하다. | `main()` 루프 전체 — `target_*` 계산에 어떤 실측 모션 피드백도 관여하지 않음 |
| 2 | **헤딩/자세(x, y, θ) 추정 자체가 없다.** 주행거리(trip meter)는 4륜 엔코더 델타의 중앙값이라는 스칼라 값 하나뿐이고, 회전 방향/누적 헤딩은 어디에도 저장되지 않는다. | `dist_axle` 계산부 (엔코더 tick → 거리만 산출, 각속도 적분 없음) |
| 3 | **고정 트랙폭 공식이 ICR을 로봇 중심으로 가정.** `k_skid = (track_width + wheelbase²/track_width) / 2`는 Yi 2009가 지적하는 "단순화된 근사"이며, 실제 좌/우/로봇 ICR이 중심에서 벗어나는 지면 마찰 효과를 반영하지 않는다. | `k_skid`, `effective_w` 계산부 |
| 4 | **매직넘버로 근사 오차를 손땜빵.** `spin_arc_bias=0.12`(제자리 회전 시 스크럽 마찰 완화용 임의 전진 비율), `spin_jerk_rate=220`(제자리 회전만 저크를 더 낮춤) 등은 모두 "ICR이 실제로는 중심이 아니다"라는 동일 현상을 실측 없이 경험적으로 조정한 값들이다. | 코드 상단 파라미터 선언부 주석 |
| 5 | **슬립 진단이 전류만으로 이루어지는 간접 추정.** `classifyStall`/`classifyAirPair`는 지령 대비 실제 RPM과 전류만 보고 개별 바퀴 상태를 추정한다. 전류는 부하·경사·배터리 전압 새그에도 영향받으므로, "로봇 전체가 실제로 회전/가속하고 있는가"라는 직접적 진단은 불가능하다. | `classifyStall()`, `classifyAirPair()` |
| 6 | **직진 시 편향(drift) 자동 보정 수단이 없다.** 좌/우 지면 마찰이 다르면 동일 RPM을 줘도 로봇이 서서히 한쪽으로 틀어지는데, 이를 감지할 헤딩 기준 자체가 없어 순전히 운전자가 조이스틱으로 수동 보정해야 한다. | 없음 (기능 자체 부재) |
## 6.3 Yi 2009 논문이 제시하는 제어/추정 방식 (요약)
1. **3-ICR 운동학 모델**: 좌측 휠(트랙), 우측 휠(트랙), 로봇 몸체 각각에 별도의 순간회전중심
(ICR_l, ICR_r, ICR_b) 좌표를 부여한다. 기존 "트랙폭 하나로 결정되는" 모델을 일반화한 것으로,
`(v_l, v_r) → (v_x, ω)` 관계가 이 3개 ICR 좌표를 파라미터로 하는 선형식이 된다.
2. **오프라인/온라인 캘리브레이션**: ICR 좌표는 고정 상수로 가정하지 않고, 실측 모션(외부
기준 또는 IMU)에 대해 최소자승으로 피팅하거나 EKF 상태의 일부로 추정한다. Pentzer 2014는
이를 한 발 더 발전시켜 **속도·지형에 따라 ICR이 실시간으로 변한다고 보고 온라인으로 계속
추정**하는 방식을 제안한다.
3. **IMU-EKF 융합**: 자이로 요레이트는 슬립과 무관하게 로봇의 실제 회전각속도를 직접 측정한다.
이를 ICR 모델의 휠-기반 예측치와 EKF로 결합하면, 저가 스트랩다운 IMU + 휠 인코더만으로도
위치/헤딩 추정 정확도가 크게 향상됨을 실험으로 보인다.
4. **부가 효과 — 잔차 기반 슬립 검출**: 휠 기반 ω 예측치와 자이로 실측치의 차이(잔차) 자체가
정량적 슬립 지표가 된다. 이는 §6.2-5에서 지적한 "전류만으로 하는 간접 추정"보다 훨씬 직접적인
신호다.
## 6.4 단계별 개선 계획 (Phase 1 → 5, 각 단계는 독립적으로 가치가 있고 이전 단계에 얹는 구조)
> 설계 원칙: 각 Phase는 **먼저 관측/로깅으로 검증**한 뒤에만 제어 루프에 개입하도록 순서를
> 잡았다. 검증 없이 곧바로 폐루프 보정을 넣으면, IMU 노이즈나 마운트 문제를 슬립으로 오판해
> 오히려 제어가 나빠질 위험이 있기 때문이다(§6.6 리스크 참고).
### Phase 0 — IMU(HWT905) 정식 매뉴얼 확보 및 레지스터 맵 문서화 (선행 작업)
* IMU 하드웨어는 **WitMotion HWT905-RS485**로 확정 (근거: `QnA/a1.md` §Q4 — 9축 AHRS 융합
출력, RS485/Modbus 프로토콜 지원, IP67, 최대 200Hz).
* RS485+Modbus라는 점에서 `xbox_motor_control.cpp``SerialPort`(Modbus RTU, CRC16,
`readReg`/`writeReg`/`readRegs`)를 상당 부분 재사용 가능할 것으로 예상되나, **정확한
레지스터 주소(각속도/각도/가속도/지자기, 출력레이트, 슬레이브 주소 설정)는 아직 확정하지
못했다** — 확보한 정식 PDF가 이미지 스캔본이라 텍스트 추출이 안 됐기 때문.
* 실행 순서: (1) 텍스트 검색 가능한 정식 매뉴얼을 `resource/`에 추가 → (2) ZLAC8015D 문서와
동일한 정밀도로 `doc/07-imu-hwt905-spec.md`(가칭) 작성 → (3) 그 다음에야 Phase 1 코드 작업
착수.
* **버스 분리 여부 결정 필요**: ZLAC8015D 드라이버 2대가 쓰는 RS485 버스에 IMU를 슬레이브로
얹을지, 별도 USB-RS485 포트로 분리할지는 실측(3개 슬레이브를 한 버스에 얹었을 때 100Hz
루프의 `comm_ms` 여유)으로 결정한다(§6.7 참고).
### Phase 1 — IMU 연동 + 순수 관측 (제어 루프 미개입)
* Phase 0에서 확정한 레지스터로 HWT905에서 요레이트(Z축 자이로) 최소, 가능하면 roll/pitch도
함께 읽는다. 장착 위치는 차체 무게중심에 근접하고 진동이 적은 곳.
* 별도 스레드에서 100Hz 이상으로 폴링해 값을 전역/원자적으로 공유(기존
`LidarObstacleDetector`가 이미 별도 스레드+뮤텍스 패턴을 쓰고 있으므로 동일 패턴 재사용
가능).
* **제어 로직은 건드리지 않는다.** 요레이트 값을 HUD 한 줄에 추가 표시하고, `--log` CSV에
`imu_gyro_z` 컬럼만 추가.
* 목적: 실제 하드웨어 노이즈 특성, 진동 영향, 드리프트/바이어스 크기를 먼저 눈으로 확인.
HWT905는 온보드 AHRS 필터를 내장하므로(`QnA/a1.md` §Q3), 애플리케이션 레벨에서 별도
저역통과/상보필터를 얼마나 더 추가해야 하는지는 이 단계의 실측으로 판단한다.
### Phase 2 — 헤딩(θ) 적분 + x,y 오도메트리 노출
* 자이로 요레이트를 단순 적분해 헤딩 θ를 추정하고, 기존 `dist_axle`(휠 기반 선속도 크기)과
결합해 x, y 위치도 함께 적분.
* 휠 기반 ω(현재 `k_skid` 공식으로부터 나오는 값)와 IMU 기반 ω를 **나란히** HUD/CSV에 출력해
두 추정치가 실제로 얼마나 차이 나는지 정량적으로 확인. 이 차이가 바로 §6.3-4의 슬립 잔차다.
* 아직 제어에는 반영하지 않음 — Phase 1과 마찬가지로 "계측 단계".
### Phase 3 — 직진 헤딩 홀드 (첫 폐루프 개입)
* 조향 입력(`lx`, `rx`)이 0이고 전진/후진 중일 때, θ를 "락(lock)"하고 IMU 실측 요레이트가
0에서 벗어나면 작은 PI 트림을 좌/우 `cmd_*`에 더해 편향을 보정.
* 개입 폭에 상한을 두어(예: 최대 트림 RPM 제한) 사용자의 의도적 조향을 절대 압도하지 않게 함.
* 이 단계부터 "운전자가 매번 손으로 직진 편향을 보정하는" 현재의 투박함이 실질적으로 개선됨.
### Phase 4 — IMU 기반 전신(whole-body) 슬립 감지 (기존 전류 진단 보완)
* 지령 ω 대비 IMU 실측 ω의 오차가 임계값을 넘으면 "전체 슬립"(예: 젖은 잔디, 빙판, 4륜 동시
헛돎)으로 판정 — 기존 `classifyStall`/`classifyAirPair`(개별 휠·전류 기반)와는 독립적인
신호이므로 서로 교차검증하는 2중 진단 체계가 됨.
* 전체 슬립 판정 시 속도를 일시적으로 낮추는 등, 기존 라이다 회피의 `speed_scale` 메커니즘과
유사한 패턴으로 개입 가능.
### Phase 5 — ICR 실측 캘리브레이션으로 매직넘버 제거 (정적 → 온라인 확장)
* **5a. 정적 캘리브레이션 (먼저 검증)**: 다양한 속도·회전반경 조합으로 주행하며 지령 대비 IMU
실측 ω를 기록, 최소자승으로 유효 트랙폭/ICR 오프셋을 피팅. `k_skid`, `effective_w`, 그리고
경험적으로 넣은 `spin_arc_bias=0.12` 같은 값을 이 실측 피팅값으로 교체하거나 근거를 마련.
* **5b. 온라인 ICR 추정 (선택적 확장, `QnA/a1.md` §Q2)**: ICR은 실제로는 경사·무게중심·지형
마찰·트레드 마모에 따라 계속 변하는 값이므로(Pentzer 2014), 5a의 정적 상수 대신
`effective_ICR_gain = 실측_요레이트(IMU) / 지령_차동속도`를 매 틱 갱신하는 방식으로 확장할
수 있다. 구현/검증 부담이 커지므로 5a가 실외에서 충분히 검증된 뒤에만 착수.
* 커밋 이력(`19fb70d Fix curve-steering wheel-reversal at partial throttle`,
`ae3f92f Improve outdoor traction control`)에서 반복적으로 나타난 "저속/부분 스로틀에서
커브가 이상하게 도는" 류의 버그들은 상당수가 이 근사식의 부정확성에서 기인했을 가능성이
높다 — 근본 원인 제거 시도.
### Phase 6 (장기, 선택) — 자율주행 확장 가능성
* Phase 2에서 만든 x,y,θ 추정을 Livox Mid-360S(이미 장착, 현재는 반응형 회피에만 사용)와
결합하면 SLAM/웨이포인트 주행으로 자연스럽게 확장 가능. 이번 계획의 범위 밖이지만, IMU
도입이 갖는 장기적 가치로 기록해 둔다.
## 6.5 Husky 실제 소프트웨어 스택과의 대응 관계
| Husky (`robot_localization` 기반) | 본 계획의 대응 Phase |
| :--- | :--- |
| 휠 인코더 오도메트리 (`/husky_velocity_controller/odom`) | 이미 존재 (`readFeedback()` 기반 `l_fb`/`r_fb`, `l_tick`/`r_tick`) |
| IMU (`/imu/data`) | Phase 1 |
| `ekf_localization_node` (휠+IMU 융합 → `/odometry/filtered`) | Phase 2, 장기적으로 정식 EKF로 고도화 가능 |
| 헤딩 기반 주행 안정성 | Phase 3 |
| Nav Stack 기반 자율주행 | Phase 6 (장기) |
## 6.6 리스크 / 트레이드오프
* **진동 노이즈**: 실외 오프로드 특성상 차체 진동이 크다. 저역통과/상보필터 없이 자이로 값을
바로 쓰면 오히려 노이즈를 슬립으로 오판할 수 있다 — Phase 1의 "순수 관측" 단계를 반드시
거쳐 실제 노이즈 특성을 먼저 파악해야 한다.
* **마운트 위치**: 무게중심에서 멀거나 진동이 심한 곳에 달면 가속도계 값이 왜곡된다. 요레이트는
위치에 덜 민감하지만 완전히 자유롭지는 않다.
* **추가 통신/스레드 부하**: 100Hz 제어 루프에 영향을 주지 않도록 IMU 폴링은 별도 스레드로
분리(라이다와 동일 패턴)하고, 메인 루프는 원자적 읽기만 수행해야 한다.
* **개발/검증 비용**: Phase 3 이후의 폐루프 개입은 실외 실주행 테스트로만 튜닝 가능하다 —
단계별로 나눠 각 단계를 충분히 검증한 뒤 다음 단계로 넘어가는 접근이 필요하다.
## 6.7 결정이 필요한 사항
| # | 항목 | 상태 |
| :-: | :--- | :--- |
| 1 | **목표 범위**: Phase 3(헤딩 홀드)까지만 목표로 할지, Phase 6(자율주행 확장)까지 염두에 두고 설계할지 — Phase 2 오도메트리 구조(단순 적분 vs 정식 EKF)에 영향 | 미결정 |
| 2 | **IMU 하드웨어** | **결정됨 — WitMotion HWT905-RS485** (`QnA/a1.md` §Q4) |
| 3 | **RS485 버스 분리 여부**: IMU를 ZLAC8015D와 같은 버스에 얹을지, 별도 포트로 분리할지 | 미결정 — Phase 1 실측 후 결정 |
| 4 | **시작 지점**: Phase 0(매뉴얼 확보/문서화)부터 순차 진행할지, 특정 Phase만 우선 원하는지 | 미결정 |
이 문서에 대한 피드백을 반영해 계획을 확정한 뒤 코드 작업을 시작한다.
+235
View File
@@ -0,0 +1,235 @@
# 07. 2.5D 맵 기반 자율주행 — 현재 상태 정리와 다음 단계 (Phase 6)
> **문서 성격 변경**: 이전 버전은 "계획안, 코드 미착수"를 전제로 썼다. 그러나 실제로는 별도
> 워크스페이스 `~/fori_ws`에 이 문서가 계획하려던 내용의 상당 부분이 **이미 구현되어 동작
> 중**이다(§7.0). 그래서 이 문서는 더 이상 "무엇을 어떻게 새로 만들 것인가"가 아니라 **"무엇이
> 이미 있고, 무엇이 검증되지 않았고, 무엇을 다음에 해야 하는가"**를 정리하는 문서로 성격을
> 바꾼다. [06번 문서](06-imu-integration-plan.md)의 RC 통합 작업과는 독립적으로 진행된 별도
> 트랙이었다는 점에 유의한다.
## 7.0 두 저장소의 관계 — 왜 이 문서가 실제와 어긋나 있었는가
| 저장소 | 역할 | 상태 |
| :--- | :--- | :--- |
| `fori_zltech_motor_test`(이 레포) | RC 수동조종(Xbox/CRSF) + ZLAC8015D **Modbus RTU 프로토콜을 CRC 검증까지 포함해 실기체에서 검증**하는 테스트대. ROS2 없이 단일 C++ 프로그램(`xbox_motor_control.cpp`)으로 동작. | 계속 유지·개발 중([06번 문서](06-imu-integration-plan.md) Phase 1~5a) |
| `~/fori_ws` | ROS2 Humble 기반 완전한 자율주행 통합 스택. livox_ros_driver2, FAST-LIO(LIO), Nav2, 커스텀 시리얼 브릿지, 웹 대시보드까지 포함. | **이미 상당 부분 구현·통합 완료**, 실기체 검증은 일부만 진행 |
`~/fori_ws/fori_ws/src/fori_serial_bridge_cpp/src/serial_bridge_node.cpp` 상단 주석:
> "only the low-level serial transport is replaced with the CRC-validated, short-poll
> implementation verified on the real robot in
> `zlac8015d_motor_test/fori_zltech_motor_test/xbox_motor_control.cpp`."
즉 두 저장소는 이미 실제로 연결된 형제 프로젝트다 — 이 레포에서 검증한 Modbus 프로토콜을
`fori_ws`가 가져다 ROS2 노드로 감쌌다. 이 문서(07번)는 그 사실을 모른 채 "URDF부터 새로
작성해야 한다"는 전제로 쓰여 있었으므로, 이번에 전면 수정한다.
## 7.1 사용자가 요청한 컨셉과 실제 구현 대조
사용자가 이번에 요청한 자율주행 방향 — **이미 가진 2.5D 맵을 활용, 고도 정보로 오르막/내리막
속도 조절, Mid-360S 그대로 사용, `pcd_gunsan_output` 맵 활용** — 은 다음과 같이 **이미 구현되어
있다**.
| 요청 사항 | 구현 상태 | 근거 파일 |
| :--- | :--- | :--- |
| 2.5D 맵(포인트별 고도 포함) | 구현됨. `pcd_gunsan.pcd`(9,791,311포인트, RGB 컬러 포함, ~1.05GB)를 `pcd_gridmap_converter`가 표준 Nav2 점유격자(`map.pgm/yaml`)와 별도 고도 그레이스케일 맵(`elevation_map.pgm/yaml`)으로 변환 | `~/pcd_gridmap_converter/src/pcd_to_gridmap_node.cpp`, 출력 `~/pcd_gunsan_output/` |
| 오르막/내리막 속도 조절 | 구현됨. `TerrainSpeedNode`가 현재 위치+진행방향 0.5m 앞의 고도차(grade)를 계산해 오르막은 부스트(최대 1.3배), 내리막은 감속(최소 0.5배), EMA로 스무딩 | `~/fori_ws/fori_ws/src/fori_serial_bridge/fori_serial_bridge/terrain_speed_node.py` |
| Mid-360S 유지 | 그대로 사용. `livox_ros_driver2``/livox/lidar`, `/livox/imu` 발행 | `~/fori_ws/fori_ws/src/livox_ros_driver2/` |
| `pcd_gunsan_output` 맵 활용 | 이미 이 경로를 Nav2 `map_server``terrain_speed_node`가 직접 참조 중. 사용자가 언급한 `/home/pcd_gunsan_output`은 실제로는 `/home/yoo/pcd_gunsan_output`이다(확인 완료) | `fori_nav2_params.yaml:3`, `fori_full.launch.py:18-21` |
**`elevation_map.pgm` 디코딩 방식**(참고용): 픽셀 0=미상, 그 외에는
`z = min_elevation + (pixel-1)/254*(max_elevation-min_elevation)`로 선형 복원. 현재 저장된
범위는 `-1.99m ~ 3.00m`(`elevation_map.yaml`). 이 범위가 실제 지형 고도차를 다 담고 있는지는
Z 관심영역(ROI) 파라미터(`min_height`/`max_height`)에 달려 있는데, 과거 한 번 이 값이 필터
경계값과 정확히 일치해 잘렸을(clipping) 가능성이 제기되어 더 넓은 범위로 재변환한 이력이 있다
— §7.5 Phase 2에서 재확인이 필요하다.
## 7.2 판단 1 — "URDF 먼저, 그 다음 ros2_control" 순서가 맞는가
**결론: URDF 부분은 맞고 이미 있다. ros2_control은 불필요하다 — 이미 더 가벼운 대안으로
대체되어 잘 동작 중이다.**
### URDF: 있음, 다만 표준 패턴과 다른 구조
`~/fori_ws/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf`에 Mid-360 라이다, Hikrobot 카메라,
4바퀴(continuous joint)가 실측 치수로 정의되어 있다. 다만 **루트 링크가 `base_link`가 아니라
`aft_mapped`**다 — FAST-LIO(LIO SLAM)가 런타임에 `camera_init → aft_mapped` TF를 동적으로
발행하고, `base_link`는 그 아래에 고정 조인트로 매달린 구조(`aft_mapped_to_base`,
`xyz="-0.26953 0 -0.41296" rpy="0 -0.277309 0"`, 라이다 실측 피치 보정 포함)다.
이건 표준 로봇공학 URDF 패턴(고정된 `base_link`를 루트로 두고 오도메트리가 `odom→base_link`
발행)과 다르다 — 대신 "라이다 SLAM이 로봇의 기준 좌표계 자체를 소유하고, 로봇 형상은 그 자세를
따라간다"는 구조다. 이 선택 자체는 실용적이다(LIO가 어차피 가장 정확한 자세 추정원이므로).
다만 §7.4에서 다루는 TF 트리 충돌 문제의 근본 원인이기도 하다.
### ros2_control: 불필요 — 더 가벼운 직접 시리얼 브릿지가 이미 그 자리를 대신함
이전 버전 문서는 `hardware_interface::SystemInterface`를 구현하는 ros2_control 플러그인을
써야 한다고 했다. 실제로는 그렇게 하지 않았다. 대신 `fori_serial_bridge_cpp::ForiSerialBridge`
(일반 `rclcpp::Node`)가:
* `/cmd_vel`(Twist)을 직접 구독
* 20Hz 제어 루프에서 가감속 램프 + IMU 요레이트 PI 보정 + 차동 킨매틱스 → 좌우 RPM 산출
* ZLAC8015D에 RS485 Modbus RTU로 직접 명령(레지스터 `0x2088` 속도 쓰기, `0x20AD` 피드백 읽기)
* `/odom_wheels`, `/joint_states`, `/battery_state` 발행
이 구조로 이미 Nav2와 통합되어 동작 중이다(`serial_bridge_node.cpp:112-134`). ros2_control의
핵심 이점은 (1) `diff_drive_controller` 같은 표준 컨트롤러 재사용, (2) `controller_manager`
표준 생명주기 관리인데, 이 로봇은 애초에 4륜 스킷스티어라 표준 컨트롤러가 정확히 맞지 않고(이전
버전 문서도 §7.4.4에서 이 점을 지적했다), 커스텀 로직(가감속 램프, IMU PI 보정, 배터리 필터링,
모의주행 폴백)이 필요해 결국 커스텀 코드를 짜야 하는 건 ros2_control을 쓰든 안 쓰든 마찬가지다.
**지금 상태에서 ros2_control로 다시 감싸는 건 실질적 이득 없이 이미 검증된 경로를
재작성하는 것**이므로 우선순위가 낮다. 유지를 권장한다.
### 다만 놓친 부분 하나 — 스킷스티어 캘리브레이션 미이식
`fori_serial_bridge_cpp`의 킨매틱스는 순수 기하학적 차동구동 공식을 쓴다
(`v_left = v - w*wheel_base/2`, `wheel_base=0.374`m, `serial_bridge_node.cpp:344-346`). 반면
이 레포의 `xbox_motor_control.cpp`는 [06번 문서](06-imu-integration-plan.md) Phase 5a에서
실측으로 캘리브레이션한 `effective_w=0.87`, `k_skid=0.51`(`xbox_motor_control.cpp:1449,1459`)을
쓴다 — 이 값들은 스킷스티어 로봇의 실제 타이어-지면 마찰로 인한 유효 회전반경이 순수 기하학적
트랙폭(0.374m)과 다르다는 걸 실측으로 확인한 결과다. **`fori_ws`의 회전 킨매틱스는 이 캘리브레이션을
이식받지 못했다** — 즉 Nav2가 "이만큼 회전해라"라고 지시했을 때 실제 회전량이 계산값과 얼마나
다른지가 검증되지 않은 상태다. 이건 §7.5 Phase 1의 작업 항목으로 넣는다.
## 7.3 판단 2 — Nav2 글로벌맵 + 로컬맵 기반 동적 장애물 회피
**결론: 맞고, 이미 사용자가 제안한 것보다 한 단계 더 나아간 3중 구조로 구현되어 있다.**
| 계층 | 역할 | 데이터 소스 | 근거 |
| :--- | :--- | :--- | :--- |
| `global_costmap` | 정적 전역 지도(`static_layer`) + 관측된 장애물(`obstacle_layer`)로 전역 경로 계획 | `map` 토픽(사전 구축 맵) + `/scan` | `fori_nav2_params.yaml:104-127` |
| `local_costmap` | 로봇 주변 3×3m 롤링 윈도우, 실시간 장애물만 반영해 국소 회피 | `/scan`(라이다 실시간) | `fori_nav2_params.yaml:75-102` |
| **Collision Monitor**(추가 안전계층) | Nav2 코스트맵과 별개로 최종 속도 명령 단계에서 급출현 장애물을 감속(`SlowZone`)·정지(`StopZone`)·예측정지(`FootprintApproach`)시키는 마지막 게이트 | `/scan` | `fori_collision_monitor_params.yaml` |
Collision Monitor는 `terrain_speed_node`가 낸 `/cmd_vel_terrain`(경사 보정까지 끝난 명령)을
받아 최종 `/cmd_vel`을 낸다 — 즉 실제 파이프라인은 `Nav2 계획 → 경사 보정 → 급출현 장애물
감속/정지 → 실제 모터`(§7.5 아키텍처 그림 참고) 순서로, 사용자가 제안한 "글로벌은 지도로,
로컬은 라이다로" 구조를 그대로 따르면서 한 겹의 실시간 안전장치를 더 두고 있다.
**알려진 한계 하나**: Nav2의 `behavior_server`(막힘 시 후진/제자리회전 등 복구 동작)가 내는
속도 명령은 `nav2_bringup` 내부 리맵 구조상 `velocity_smoother`를 거치지 않아
`terrain_speed_node`의 경사 보정과 Collision Monitor의 급출현 감속·정지를 **우회**한다
(`fori_nav2.launch.py:128-137` 주석에 상세 근거). 다만 복구 동작 자체가 저속·단거리로 제한되고
`serial_bridge_node``target_linear_speed`(0.3m/s) 하드 클램프는 경로와 무관하게 항상
적용되므로 위험은 제한적이지만, 완전히 무시할 문제는 아니다.
## 7.4 질문에 대한 답
### Q1. 저장된 맵을 제공하고 그 맵으로 Nav2를 진행해도 실시간 자세 추종이 가능한가?
**가능하다.** "SLAM(지도를 만들면서 동시에 자세를 추정)"과 "Localization(이미 있는 지도
안에서 자세만 추정)"은 다른 문제다. 후자는 지도를 새로 만들 필요가 없어 계산량이 훨씬 가볍고,
이게 정확히 Nav2의 **AMCL**(Adaptive Monte Carlo Localization)이 하는 일이다. `fori_ws`에는
이미 이 구조가 설정되어 있다:
* `map_server``pcd_gunsan_output/map.yaml`(사전 구축된 정적 지도)을 서빙
* `amcl``scan_topic: "scan"`을 구독 — Mid-360의 3D 포인트클라우드를
`pointcloud_to_laserscan`으로 2D로 투영한 것(`fori_nav2.launch.py:88-103`)
* AMCL은 파티클 필터로 이 2D 스캔을 정적 지도와 계속 대조해 `/amcl_pose`(map 프레임 기준 위치)를
실시간으로 발행
즉 SLAM처럼 지도를 다시 만들 필요 없이, **저장된 지도 하나만 있으면 실시간 위치 추정이 가능**
하다는 것이 맞다.
### Q2. 그렇다면 라이다로 주행 중 저장된 맵과 계속 대조하며 자세를 추종하는 것인가?
**맞다 — 그게 AMCL이 하는 정확한 방식(스캔매칭 기반 파티클 필터)이다.** 다만 `fori_ws`
**실제 구현에는 중요한 예외가 있고, 이게 지금 이 시스템의 가장 중요한 미해결 갭이다.**
`fori_nav2_params.yaml:25`에서 AMCL의 `tf_broadcast: false`로 꺼져 있다. 이유는 코드 주석에
명시되어 있다:
> FAST-LIO(LIDAR SLAM)가 `camera_init→aft_mapped→base_link` TF를 이미 발행하고 있는데, AMCL이
> 표준대로 `map→odom`을 또 발행하면 `odom`의 부모를 두 곳(FAST-LIO의 정적 브릿지 vs AMCL)이
> 동시에 주장하게 되어 `base_link↔odom` 연결을 tf2가 못 찾는 "unconnected trees" 오류가 난다.
그래서 실제로는 `camera_init``odom``map` 양쪽에 **정적(변화 없는 identity transform)**
으로 동시에 연결해 놓았다(`static_tf_camera_init_node`, `static_tf_camera_init_to_map_node`,
둘 다 `fori_nav2.launch.py:48-70`). 결과적으로:
* **실제 항법(Nav2 코스트맵/플래너가 보는 "map" 프레임)은 FAST-LIO가 만드는 연속적인
LiDAR-관성 오도메트리(LIO) 그 자체**다 — 이건 저장된 지도와 무관하게, 매 순간 이전 프레임
대비 상대 이동만 누적하는 방식이라 시간이 지나면 드리프트가 쌓인다.
* AMCL은 여전히 돌고 있고 `/amcl_pose`(진짜 저장된 지도와 스캔매칭한 결과)를 정상 발행하지만,
이 값은 **`terrain_speed_node`/`parking_controller_node`가 위치 조회용으로 참고만 할 뿐,
Nav2의 TF 트리·코스트맵에는 재반영되지 않는다.**
정리하면: **지금은 "저장된 맵과 계속 대조해서 드리프트를 실제로 보정하는" 루프가 완성되어 있지
않다.** AMCL은 돌고 있지만 보조적 위치 확인 용도이고, 로봇이 실제로 어디 있다고 "믿고" 주행하는
좌표계는 FAST-LIO의 순수 오도메트리다. 짧은 거리·짧은 시간의 순찰(사람이 대기하며 감독하는
Phase 4~5 수준)에서는 드리프트가 실용적으로 무시할 만하겠지만, **주행 거리·시간이 늘어날수록
"로봇이 믿는 위치"와 "저장된 지도 위 실제 위치"가 벌어질 위험**이 있다 — 이건 §7.1에서 확인한
사용자의 요청(저장된 2.5D 맵을 활용한 자율주행)의 정확도에 직접 영향을 준다. 이 TF 충돌을 풀어
AMCL의 보정이 실제로 항법 좌표계에 반영되게 하는 것을 §7.5 Phase 1의 최우선 항목으로 둔다.
## 7.5 Phase — 남은 작업 (기존 코드 근거 기반으로 재정의)
이전 버전 문서의 Phase 1~3(인프라 확인/실내 SLAM 검증/야외 검증)은 `fori_ws`에서 이미 그
이상으로 진행되어 있으므로 폐기한다. 아래는 §7.2~7.4에서 확인한 **구체적 갭**을 기준으로 다시
짠 순서다. [06번 문서](06-imu-integration-plan.md)와 같은 원칙 — 먼저 관측/검증, 그 다음에만
주행 제어 개입 — 을 유지한다.
### Phase 1 — 자세 추종 정확도 갭 해소 (최우선, 주행 제어 영향 큼)
* **AMCL↔FAST-LIO TF 통합**: `unconnected trees` 문제를 임시 봉합(정적 aliasing)이 아니라 실제로
풀어, AMCL의 스캔매칭 보정이 `map→odom` 형태로든 다른 형태로든 Nav2가 실제로 쓰는 좌표계에
반영되게 한다. 후보: (a) FAST-LIO 쪽에서 `aft_mapped``odom`으로 두고 AMCL이 표준대로
`map→odom`을 발행하도록 프레임 이름 재배선, (b) `robot_localization``navsat_transform` 유사
패턴으로 AMCL 보정치를 주기적으로 오도메트리에 융합. §7.4 Q2에서 확인한 현재 구조(정적
aliasing)로는 저장된 지도 대비 실제 위치 보정이 이름만 있고 실질적으로 동작하지 않는다.
* **스킷스티어 캘리브레이션 이식**: §7.2에서 확인한 `k_skid=0.51`/`effective_w=0.87`(06번 문서
Phase 5a 실측값)을 `fori_serial_bridge_cpp`의 회전 킨매틱스에 반영. 지금은 순수 기하학적
트랙폭(0.374m)만 쓰고 있어 Nav2가 지시한 회전량과 실제 회전량이 얼마나 다른지 검증되지 않았다.
* **게이트**: 위 두 항목을 고친 뒤, 로봇을 손으로/저속으로 움직이며 `/amcl_pose`와 로봇의 실제
위치(줄자·표식 등 외부 기준)를 비교해 보정이 실제로 드리프트를 줄이는지 확인해야 다음 단계로
넘어간다.
### Phase 2 — 맵/웨이포인트 데이터 검증
* **고도맵 ROI 재확인**: `elevation_map.yaml``min_elevation/max_elevation`이 실제 군산
부지의 지형 고도차를 다 담고 있는지(§7.1에서 언급한 과거 클리핑 이력) `pcd_to_gridmap_node`
`min_height`/`max_height` 파라미터를 재확인하고 필요하면 넓혀서 재변환한다.
* **순찰 웨이포인트 설정**: `parking_controller_node``patrol_waypoints`가 현재 빈 배열이다
(예전 실내 지도 좌표는 새 군산 부지에 안 맞아 이미 비워둔 상태 — `parking_controller_node.py:47`
주석 참고). rviz2로 새 지도를 열어 순찰 좌표를 실측/지정한다.
* **게이트**: rviz에서 라이다 스캔이 정적 지도와 정렬되어 보이고, 지정한 웨이포인트가 지도상
주행 가능 영역(occupancy=free) 안에 있는지 확인.
### Phase 3 — 통제 구역 저속 폐루프 테스트
* 울타리 등으로 막힌 좁은 구역, 저속, 사람이 항시 대기하며 즉시 개입 가능한 상태에서 Phase 1~2
적용 후 첫 자율주행 테스트.
* 오르막/내리막 구간을 포함해 `terrain_speed_node`의 속도 보정이 실제로 등판력 확보/안전한
서행으로 이어지는지 확인(§7.4의 게인 파라미터 `uphill_gain=3.0`/`downhill_gain=4.0`이 이
로봇 중량·모터 토크에 적절한지는 아직 실측 검증 전).
* [06번 문서](06-imu-integration-plan.md) Phase 4(전신 슬립 감지)가 이 자율주행 경로 위에서도
살아있는지 — 즉 `fori_serial_bridge_cpp`로 옮겨 갔는지, 아니면 아직 `xbox_motor_control.cpp`
에만 있는지부터 확인해야 한다. 현재 `serial_bridge_node.cpp`를 보면 슬립 감지·온도 모니터링
로직은 이식되지 않은 것으로 보인다 — 이식 여부를 이 Phase에서 명시적으로 판단한다.
* **게이트**: 짧은 구간 반복 검증이 안정적이어야 범위를 넓힌다.
### Phase 4 — 범위 확장 + 동적 장애물 실지 검증
* 더 길고 개방된 구역, 사람/물체가 실제로 튀어나오는 상황에서 Collision Monitor의
StopZone(0.45m)/SlowZone(0.90m)/FootprintApproach(1.5초 예측)가 의도대로 동작하는지 확인.
* §7.3에서 확인한 `behavior_server` 복구 동작의 안전장치 우회 한계가 실제로 문제되는지(복구가
얼마나 자주 트리거되는지) 관찰.
### Phase 5 — (낮은 우선순위, 보류) ros2_control 전환 재검토
§7.2에서 판단했듯 지금은 불필요하다. 다만 이후 표준 ROS2 컨트롤러 생태계(예: 다른 로봇과 코드
공유, `moveit`류 확장 등) 활용 필요성이 실제로 생기면 그때 재검토한다 — 지금 시점에 선제적으로
할 이유는 없다.
## 7.6 요약 — 이번 조사에서 바뀐 것
* 이전 문서는 "무엇을 어떻게 새로 만들 것인가"를 물었지만, 실제로는 **이미 만들어져 있었다.**
URDF, 2.5D 맵 변환, 경사 기반 속도 조절, Nav2 글로벌/로컬 코스트맵 + Collision Monitor까지
전부 `~/fori_ws`에 구현되어 있다.
* ros2_control은 필요 없다 — 더 가벼운 직접 시리얼 브릿지가 이미 그 역할을 하고 있고, 지금
다시 쓰는 건 순수 재작업이다.
* 가장 중요한 실질적 갭은 두 가지다: **(1) AMCL의 지도 대조 보정이 TF 충돌 회피용 임시방편
때문에 실제 항법 좌표계에 반영되지 않고 있다는 것**(§7.4 Q2), **(2) 06번 문서에서 실측
캘리브레이션한 스킷스티어 보정값(`k_skid`/`effective_w`)이 ROS2 스택의 킨매틱스에 이식되지
않았다는 것**(§7.2). 이 둘이 Phase 1의 핵심이다.
+207
View File
@@ -0,0 +1,207 @@
# 08. 로봇 제어 시스템 개선 보고서
> 이 문서는 `feat/radiomaster-rc-control` 브랜치에서 진행한 개선 작업을 정리한 보고서다.
> 모든 항목은 실제 로봇 실주행 로그로 검증을 거쳤으며, 정량적 결과는 실측 CSV 로그 분석
> 기반이다. 관련 상세 계획/근거는 [06-imu-integration-plan.md](06-imu-integration-plan.md),
> [joystick.md](joystick.md)에 별도로 정리되어 있다.
## 개요
기존 Xbox 컨트롤러(SDL2) 기반 조종을 RadioMaster Pocket + XR1 RC 수신기로 교체하면서 시작해,
IMU(WitMotion HWT905) 도입을 통해 로봇이 "지령한 대로 실제로 움직이고 있는지"를 처음으로
확인·보정할 수 있게 만들었다. 그 결과 제자리 회전 성능이 지령 대비 실측 기준 **44~58% →
93.6%**로 크게 개선되었고, 직진 헤딩 자동 유지, 전신 슬립 감지, 모터 과열 조기 감지 등의
안전/편의 기능이 추가되었다.
## 1. RC 조종 시스템 전환
* Xbox/SDL2 조이스틱을 RadioMaster Pocket + XR1 수신기(CRSF 프로토콜, 420000bps)로 교체.
* 실측 하드웨어 캘리브레이션(CH1 조향/CH2 전후진/CH5 브레이크/CH6 속도모드) 및 Fail-safe
타임아웃 적용.
**문제**: 실측 중 채널 값이 간헐적으로 엉뚱한 값으로 튀는 현상 발견(예: 스틱을 살짝만
움직였는데 최대 후진값이 순간적으로 찍힘). CRSF 프로토콜은 표준상 CRC-8 체크섬으로 손상된
프레임을 걸러낼 수 있는데, 이 특정 수신기 하드웨어에서는 표준 CRC-8(DVB-S2) 및 가능한
변형(다항식/초기값/반전 조합 전수조사, 보율 오차 가설 등)을 모두 실측 데이터로 대조해봐도
일치율이 최대 33%에 그쳐 **이 하드웨어에서는 CRC 검증 자체가 신뢰할 수 없다**는 결론을 실험적으로
확정.
**적용 방식**: CRC 대신 3단계 방어를 조합했다.
1. **범위 검증**: 캘리브레이션으로 확인된 정상 범위를 벗어나는 값은 무조건 거부.
2. **슬루레이트(변화율) 제한**: 한 틱 사이에 물리적으로 불가능할 만큼 큰 변화는 손상 프레임으로
간주해 거부하되, 진짜 빠른 스틱 조작까지 막지 않도록 N틱 연속 거부되면 강제로 재동기화하는
예외 처리를 둠(그렇지 않으면 채널이 영구히 멈추는 결함이 있었음 — 발견 후 수정).
3. **중앙값(median-of-3) 필터**: 최근 3개 값의 중앙값을 취해 단발성 튐값의 영향을 한 번 더
줄임.
* udev 규칙으로 `/dev/ttyMOTOR`/`/dev/ttyRC`/`/dev/ttyIMU` 고정 심볼릭 링크 적용 — USB 재연결
시 포트 번호가 매번 바뀌던 문제 해결.
## 2. 주행 제어 안정성 개선
* **브레이크**: 기존엔 스로틀을 중립으로 되돌려 자연 정지할 때도 전자식 브레이크가 자동으로
잠겼는데, 이를 CH5 스위치를 눌렀을 때만 잠기도록 변경.
* **가속/감속 — 저크 제한(jerk-limited) S-curve란**: 속도(RPM)를 목표까지 바로 바꾸는 게 아니라,
"가속도가 변하는 속도(저크)" 자체에 상한을 둬서 가속도 곡선을 매끄러운 S자로 만드는 방식이다.
급격한 가속도 변화는 큰 전류 스파이크(역기전력)로 이어질 수 있어, 감속에는 계속 이 방식을
유지한다. 다만 하드웨어 백EMF 보호회로 구성이 완료되면서 **가속은 더 이상 소프트웨어로 완만하게
램프업할 필요가 없어져, 조이스틱 목표값에 1:1로 즉시 추종하도록 변경**했다(감속은 기존과 동일).
* **정지 직전 반전 방지**: 위 "즉시가속" 로직은 "목표값이 현재값보다 커지면 무조건 즉시 반영"
하는 단순 조건이었는데, 정지 막바지 저속 구간에서 RC 노이즈로 목표값이 순간적으로 반대 부호로
흔들리면 이 조건이 그대로 성립해버려 "전진→후진→전진"처럼 튀는 현상이 있었다. **적용 방식**:
조건을 "같은 방향으로 더 빨라지는 경우"로 한정하고, 부호가 바뀌는 경우(RC 노이즈든 실제
방향 전환 지시든)는 전부 저크 제한 감속 경로로 보내 0을 관통할 때도 부드럽게 처리하도록 수정.
## 3. IMU 통합 — 오도메트리 (Phase 1~2)
* WitMotion HWT905-RS232 IMU를 별도 USB 포트로 연동, 전원 인가 시점의 방향을 정면(0°) 기준으로
저장.
**적용 방식 — 데드 레커닝(dead reckoning)**: GPS 없이 "지금까지 어느 방향으로 얼마나 움직였는지"를
누적 적분해서 현재 위치(x, y)를 추정하는 고전적 방식이다. 매 제어 주기(틱)마다 (1) 바퀴 인코더
피드백으로 계산한 이동 속도와 (2) 그 순간의 헤딩(진행 방향)을 곱해 이동한 x, y 변위를 누적한다.
정확도는 전적으로 **헤딩을 얼마나 정확히 아는가**에 달려 있다 — 헤딩이 조금만 틀려도 매 틱
누적되며 오차가 기하급수적으로 벌어지기 때문에, 이번 작업에서 가장 공들인 부분이 헤딩 소스
선정이었다.
* 바퀴 엔코더 기반 위치 적분(x, y)과 회전율(wheel_omega) 대 IMU 실측 회전율(imu_omega) 비교
로직 추가.
* **헤딩 소스 결정 — 실측 검증으로 설계를 뒤집은 사례**: 처음엔 상식적으로 "IMU 자체 내장
융합 yaw(가속도+자이로를 자체적으로 결합해 계산한 값)가 원시 자이로를 그냥 적분하는 것보다
안정적일 것"이라 예상하고 그렇게 구현했다. 그런데 **검증 방법으로 "제자리로 돌아오는
루프백 주행"을 설계**해서(직선/원 등 정해진 경로로 나간 뒤 출발점으로 복귀시켜, 오차가
없다면 최종 위치가 (0,0)에 가까워야 한다는 원리) 실측해보니 시작점 대비 **20.8m나 이탈**하는
심각한 오차가 나왔다. 원인을 더 파보니 IMU가 자체적으로 계산해 내놓는 융합 yaw가 **자기
자신의 원시 자이로 방향과도 1초 평균 기준 72%밖에 일치하지 않음**을 발견했다(저가 IMU가
모터 전류로 인한 자기장 간섭 등으로 내부 지자기 보정이 흔들리는 것으로 추정). **이후 헤딩
소스를 바퀴 인코더로 추정한 회전율과 원시 자이로 값의 평균(단순 상보 융합)으로 교체** —
이 두 신호는 서로 독립적인 센서(엔코더 vs IMU)인데도 부호/크기 일치율이 99.3%로 매우
높아 신뢰할 수 있음을 먼저 확인한 뒤 채택했다.
* **재검증**: 동일한 루프백 테스트를 다시 수행해 이탈 거리가 **20.8m → 0.55m**로 개선됨을
확인(22m 주행 기준 약 2.5% 오차) — 같은 검증 방법을 "전/후 비교"에도 그대로 재사용해
설계 변경의 효과를 정량적으로 입증했다.
## 4. 직진 헤딩 자동 유지 (Phase 3)
* 조향이 중립이고 실제로 주행 중이며 라이다 회피가 개입하지 않을 때, 진입 시점의 헤딩을
목표로 고정하고 **PI 제어**로 좌우 편향을 자동 보정.
**적용 방식 — PI 제어**: 목표 헤딩과 현재 헤딩의 차이(오차)를 계속 관찰하면서, (1) **P(비례)항**
— 오차가 클수록 더 크게 보정하고, (2) **I(적분)항** — 작은 오차가 오래 지속되면(예: 좌우 노면
마찰 차이로 인한 지속적 편향) 그것까지 누적해서 보정을 강화하는 두 요소를 더해 좌우 바퀴 속도에
미세한 트림(보정값)을 얹는 방식이다. 트림 값에는 상한을 둬서 사용자의 의도적 조향 입력을 절대
압도하지 않도록 제한했고, 적분항에도 별도 상한(anti-windup)을 둬 트림이 무한정 커지는 것을 방지.
* 실주행 검증(다양한 노면, 총 20분 이상): 평균 오차 0.48~1.24°, 최대 오차 10° 이내, 트림
값은 상한(±0.15 rad/s)의 최대 38%까지만 사용 — 포화·진동 없이 안정적으로 수렴.
## 5. 전신 슬립 감지 (Phase 4)
**적용 방식 — 독립 신호 교차검증**: 기존 걸림/들뜸 판정은 "지령 RPM 대비 실제 RPM"과
"전류"만으로 개별 바퀴 상태를 추정하는데, 이 두 값 모두 바퀴 자체(엔코더/전류 센서)에서
나오는 신호라 만약 4바퀴가 동시에(예: 빙판·젖은 잔디) 헛돌면 "다들 정상적으로 지령을
따라가는 것처럼" 보여 잡아내지 못하는 사각지대가 있다. 그래서 **바퀴 시스템과 완전히
독립적인 IMU(관성 센서)로 실제 차체가 얼마나 돌고 있는지를 직접 재서 지령값과 비교**하는
이중 안전망을 추가했다: 지령 회전율과 IMU 실측 회전율의 비율이 임계값 밑으로 일정 시간
지속되면 "전신 슬립"으로 판정해 라이다 회피의 감속 패턴과 동일한 방식으로 속도를 일시적으로
낮춘다. 제자리 회전은 원래도 지령보다 덜 도는 게 정상(스크럽 마찰)이라, 오탐을 피하기 위해
"거의 안 도는" 극단적인 경우만 잡히도록 문턱값을 보수적으로 설정.
* 18분 실주행 중 11회, 0.4%의 틱에서만 발동 — 오탐 거의 없음. 가장 긴 발동 사례(1.68초)를
분석한 결과, 고속 주행 중 급선회 시 관성으로 인한 언더스티어(로봇이 거의 안 도는 상황)를
정확히 감지해 속도를 절반으로 낮췄고, 그 직후 실제로 회전이 정상적으로 회복되는 것을 확인
— 설계 의도대로 작동.
## 6. 킨매틱스 실측 캘리브레이션 — 제자리 회전 성능 대폭 개선 (Phase 5a)
### 6.1 원인 진단
제자리 회전이 지령한 만큼 돌지 않는 문제의 원인을 로그로 추적한 결과, 두 가지가 확인됨:
1. 회전 시 반력 토크로 인한 대각선 방향 하중 이동(FL+RR 또는 FR+RL 바퀴 쌍이 동시에
가벼워짐)이 정상적인 물리 현상인데, 기존 "들뜸(바퀴가 지면에서 떨어짐)" 판정 로직이 이를
오탐하여 목표 속도를 0으로 꺼버려 4륜 중 사실상 2륜만 구동되는 상황이 전체 회전 틱의
**38%**에서 발생.
2. 회전 반경 계산에 쓰이는 트랙폭 계수(`k_skid`, `effective_w`)가 기하학적 공식값으로,
실제 스킷스티어 특유의 스크럽 마찰(바퀴가 옆으로 긁히며 도는 저항)을 반영하지 못해
지령보다 작은 차동 속도를 걸고 있었음.
### 6.2 조치 및 결과
* 제자리 회전 중에는 "들뜸" 판정으로 목표 속도를 꺾는 개입을 비활성화(판정 표시는 유지).
**적용 방식 — 실측 역산(파라미터 피팅)**: 기존 `k_skid`/`effective_w`는 로봇의 트랙폭·휠베이스
치수만으로 계산한 순수 기하학적 공식값이었는데, 실제 지면에서는 바퀴가 옆으로 긁히는 저항
때문에 이 공식이 가정하는 것보다 더 큰 차동을 걸어줘야 같은 회전 속도가 나온다. 이를 정량화하기
위해 매 틱 로그에 이미 기록되고 있던 "바퀴 인코더로 계산한 회전율"과 "IMU로 실측한 실제
회전율"을 이용해, 공식을 거꾸로 풀어서(예: `유효계수 = 바퀴 기반 회전율 추정치 × 기존계수 ÷
IMU 실측 회전율`) **틱마다 "실제로 맞았어야 할 계수값"을 역산**했다. 이렇게 나온 수천 개의
값 중 이상치 영향을 줄이기 위해 평균이 아닌 **중앙값**을 대표값으로 채택했고, 서로 다른 날
수집한 **3차례의 독립적인 실주행 로그**에서 반복 계산해도 값이 거의 같게 수렴하는 것을
확인해 신뢰도를 높였다. 회전 반경별로 값을 나눠 보면 완만한 커브(0.44)와 급한 커브(0.53)
사이에서 계수가 변하는 경향도 확인했는데, 정적 상수 하나로는 이 편차를 완전히 없앨 수
없어 실용적인 절충값(중앙값)을 채택 — 이걸 실시간으로 자동 보정하는 방식(Phase 5b)도
검토했으나, 슬립이 발생하는 바로 그 순간에 추정 신호 자체가 같이 왜곡되는 구조적 문제가
있어 득보다 실이 크다고 판단해 보류했다. 아래 값으로 최종 수렴:
| 파라미터 | 기존(기하학적 공식) | 실측 캘리브레이션 |
| :--- | :--- | :--- |
| `k_skid` (커브 선회) | 0.406 | **0.51** |
| `effective_w` (제자리 회전) | 0.684 | **0.87** |
* **최종 성능**: 제자리 회전 시 지령 대비 실제 회전율(IMU 실측 기준)이 **44~58% → 93.6%**로
개선. 완전히 100%에 도달하지 못하는 잔여 오차는 스킷스티어 구조 특유의 물리적 한계(4륜
전부가 옆으로 긁히며 돌아야 하는 구조상 슬립 없이는 방향 전환이 불가능)로 판단되며, 현재
수준이 이 하드웨어에서 실용적으로 기대 가능한 한계에 근접한 것으로 평가.
* 실시간 온라인 적응형 보정(Phase 5b)은 추가 정확도 이득 대비 구현/검증 리스크가 크다고
판단해 보류 결정(근거는 06번 문서 참고).
## 7. 모터/드라이버 온도 모니터링
* 기존엔 사용하지 않던 드라이버 레지스터(모터 온도 `0x20A4`, 드라이버 온도 `0x20B0`)를 읽어
HUD/CSV에 실시간 노출.
**적용 방식**: 드라이버와의 통신은 매 제어 주기(약 27Hz)마다 이루어지는데, 통신 1회를 늘릴
때마다 제어 루프 지연이 커진다. 모터 온도 레지스터(`0x20A4`)는 마침 기존에 읽고 있던
레지스터 범위(`0x20A5`~) 바로 앞이라, **읽는 레지스터 개수만 하나 늘려서 통신 횟수 추가 없이**
얻어냈다. 반면 드라이버 자체 온도(`0x20B0`)는 기존 읽기 범위와 떨어져 있어 합치면 통신이
늘어나는데, 온도는 물리적으로 초 단위로 천천히 변하는 값이라 매 틱 갱신할 필요가 없다는 점을
이용해 **약 1.1초에 한 번만 별도로 폴링**하는 저빈도 방식을 택했다 — 정확도 손실 없이 통신
비용을 최소화한 설계.
* 60℃ 이상 시 HUD에 경고 표시.
## 8. 진단만 완료, 조치는 보류 중인 이슈
* **급정지 시 미세한 요잉(회전) 현상**: 처음엔 "조이스틱을 놓을 때 튕기는 물리적 반동" 가설을
세우고 로그의 RC 원시값 변화를 직접 확인했으나, 해당 구간에서 스틱 값은 매끄럽게 중립으로
복귀했을 뿐 반대 방향으로 튀는 값은 전혀 없어 이 가설은 기각했다. 대신 정지 이벤트마다
전후 바퀴 명령/피드백/상태 플래그를 시간순으로 대조해본 결과 두 가지 원인을 확인:
(1) 거친 노면에서 정지 직전 특정 바퀴가 순간적으로 "들뜸" 판정되어 다른 바퀴와 감속 타이밍이
어긋나는 것, (2) 드라이버 자체의 속도 제어 루프가 급정지 시 0을 살짝 지나쳤다 돌아오는
오버슈트(4바퀴가 0을 지나치는 타이밍이 서로 달라 그 사이 짧게 회전 모멘트가 생김). 감속
속도를 낮추면 완화될 여지가 있으나 "즉각 정지" 체감이 희생되는 트레이드오프가 있어 적용
여부는 보류 중.
## 9. 자동화 및 진단 인프라
* 매 주행마다 `logs/` 폴더에 타임스탬프 CSV 자동 저장(수동으로 `--log` 지정할 필요 없음) —
RC/IMU/모터 피드백/오도메트리/헤딩홀드/슬립감지/온도까지 전 항목 기록, 이번 보고서의 모든
정량적 검증이 이 로그를 근거로 이루어짐.
## 10. 향후 계획
라이다(Livox Mid-360S) 기반 SLAM/자율주행 확장 계획은
[07-lidar-slam-plan.md](07-lidar-slam-plan.md)에 별도 정리되어 있음(아직 미착수).
## 종합 결과 요약
| 항목 | 개선 전 | 개선 후 |
| :--- | :--- | :--- |
| 조종기 | Xbox/SDL2 (유선) | RadioMaster Pocket + XR1 (무선 RC) |
| 브레이크 | 자연 정지 시에도 자동 잠김 | CH5로만 제어 |
| 헤딩 인지 | 없음 | 오도메트리(x,y,θ) + 자동 직진 유지 |
| 제자리 회전 정확도(지령 대비) | 44~58% | 93.6% |
| 슬립 감지 | 개별 바퀴 전류 기반만 존재 | + IMU 기반 전신 슬립 이중 감지 |
| 모터 상태 가시성 | 전류만 | + 온도(모터/드라이버) 실시간 감시 |
| 주행 기록 | 수동 `--log` 필요 | 매 주행 자동 저장 |
+40
View File
@@ -0,0 +1,40 @@
# 드라이버 문서화 (ZLAC8015D V4)
이 문서 세트는 `resource/` 폴더의 ZLTECH ZLAC8015D V4 시리즈 서보 드라이버 매뉴얼을 분석하고,
본 저장소의 실제 제어 코드(`xbox_motor_control.cpp`)가 그 드라이버를 어떻게 사용하고 있는지
정리한 것이다. 매뉴얼 원문(영문 PDF)과 실제 구현 사이의 매핑을 명확히 하는 데 초점을 둔다.
## 로봇/드라이버 구성 요약
* **로봇 형식**: 실외 스키드 스티어(Skid-Steer) 4WD 로봇
* **모터**: ZLLG80ASM250-4096-B V2.12 허브 서보 모터 × 4 (전좌/전우/후좌/후우)
* **드라이버**: ZLAC8015D V4 (듀얼 채널 서보 드라이버) × 2대
* 드라이버 1대당 좌/우 모터 2개를 동시에 구동 (Left/Right 채널)
* 드라이버 #1 (Station ID 1) → 전방 좌/우 모터
* 드라이버 #2 (Station ID 2) → 후방 좌/우 모터
* **통신**: RS485 Modbus RTU, 115200bps 8N1, 두 드라이버가 `/dev/ttyUSB0` 한 포트에 데이지체인
* **제어 PC 소프트웨어**: `xbox_motor_control.cpp` (C++17, 100Hz 제어 루프, SDL2 Xbox 컨트롤러 입력,
Livox Mid-360S 라이다 장애물 회피 통합)
## 문서 목차
| 문서 | 내용 |
| :--- | :--- |
| [01-hardware-spec.md](01-hardware-spec.md) | ZLAC8015D 하드웨어 스펙, 커넥터 핀맵, 배선, 설치, LED 진단 |
| [02-communication-protocol.md](02-communication-protocol.md) | RS485 Modbus RTU 프로토콜과 실제 코드가 사용하는 레지스터 맵 |
| [03-system-architecture.md](03-system-architecture.md) | 로봇 물리 제원, 2드라이버×4모터 매핑, 스키드 스티어 운동학 |
| [04-software-control.md](04-software-control.md) | `xbox_motor_control.cpp` 제어 루프/클래스 구조 분석 |
| [05-error-troubleshooting.md](05-error-troubleshooting.md) | 알람 에러코드 표, 휠 이상(들뜸/걸림) 진단, 전류 한계 튜닝, 트러블슈팅 |
| [06-imu-integration-plan.md](06-imu-integration-plan.md) | IMU 도입을 통한 제어 고도화 조사 및 단계별 계획 — Husky/논문 비교 (Phase 1~5a 구현 완료) |
| [07-lidar-slam-plan.md](07-lidar-slam-plan.md) | **(계획안, 미구현)** Livox Mid-360S 기반 LiDAR SLAM 도입 계획 (Phase 6) |
06번 문서에 대한 Q&A 기록은 [`../QnA/`](../QnA/) 폴더에 별도로 누적된다 (예: [`q1.md`](../QnA/q1.md) / [`a1.md`](../QnA/a1.md)).
## 원본 자료
* `resource/ZLAC8015D V4 Series Manual Version 1.03-20251111.pdf` — 하드웨어 매뉴얼
* `resource/ZLAC8015D V4 Series RS485 Communication Version 1.06-20251111.pdf` — RS485 통신/레지스터 상세
* `resource/ZLAC8015D V4 Series RS485 Communication Quick Start Guide Version 1.00-20251111.pdf` — RS485 빠른 시작 가이드
* `resource/ZLAC8015D V4 Series CANopen Communication *.pdf` — CANopen 통신 (본 프로젝트는 RS485만 사용, 참고용)
* `resource/ZLAC8015D V4 Series.pdf` — 외형 치수도
* `resource/husky.md` — Clearpath Husky User Manual 링크 (스키드 스티어 IMU 융합 제어 비교 참고자료, [06](06-imu-integration-plan.md) 참고)
+67
View File
@@ -0,0 +1,67 @@
# [기능 명세서] RadioMaster Pocket & XR1 V1.0 기반 조종 제어 전환
## 1. 프로젝트 개요
* **목적:** 기존 Xbox 게임패드 기반의 원격 제어 모듈을 **RadioMaster Pocket RC 조종기****XR1 V1.0 수신기(USB-to-TTL 시리얼 통신)** 기반으로 교체/마이그레이션.
* **통신 환경:**
* 하드웨어: USB to TTL 컨버터 (`/dev/ttyUSB*` 또는 COM 포트)
* 수신기: XR1 V1.0
* 조종기: RadioMaster Pocket
* **실측 확인된 시리얼 프로토콜: CRSF(Crossfire), 420000bps, 8N1.**
프레임 구조 `[Sync 0xC8][Length][FrameType][Payload][CRC8]`, FrameType
`0x16`(RC_CHANNELS_PACKED)의 22바이트 payload에 16채널이 11비트씩
리틀엔디안으로 패킹되어 raw `172~1811` 값이 `1000~2000us`로 선형
변환된다. (CRC8은 이 송신기에서 표준 DVB-S2 값과 안 맞아 검증 없이
Sync+Length 프레이밍만 신뢰함.)
---
## 2. 하드웨어 및 채널 매핑 사양
### 2.1 채널별 입력 범위 및 기능 정의
| 채널 | 입력 장치 | Raw Value 범위 | 할당 기능 | 동작 설명 |
| :--- | :--- | :--- | :--- | :--- |
| **CH1** | 롤/조향 스틱 | • `1005`: 좌측 끝<br>• `1481 ~ 1503`: 중앙 (데드존)<br>• `2000`: 우측 끝 | **좌우 조향 (Steering / Angular Z)** | • 중앙 데드존(`1481~1503`) 적용<br>• 좌회전(-) / 우회전(+) 정규화 |
| **CH2** | 피치/스로틀 스틱 | • `1005`: 최대 후진<br>• `1500`: 중앙 (정지)<br>• `2000`: 최대 전진 | **전후진 (Throttle / Linear X)** | • 값이 높을수록 전진, 낮을수록 후진 (CH1과 반대 방향)<br>• CH6에서 결정된 최대 속도 기준으로 선형 매핑 |
| **CH5** | 2단 스위치 | • `1012`: 미작동 (OFF)<br>• `1988`: 눌림 (ON) | **전자/비상 브레이크 (Brake)** | • `1988` 수신 시 모든 주행 속도 즉시 0 (`Stop`)<br>• `1012` 수신 시 일반 주행 허용 |
| **CH6** | 2단 스위치 | • `1832`: 저속 위치<br>• `1012~1090`: 고속 위치 | **속도 모드 선택 (Speed Mode)** | • `1832`(≥1500 부근): 저속 모드 (최대 **0.3 m/s**)<br>• `1012~1090`(<1500): 고속 모드 (최대 **1.5 m/s**) |
---
## 3. 제어 변환 알고리즘 및 로직
### 3.1 브레이크 최우선 로직 (CH5)
* $\text{CH5} \ge 1500$ (누름 / `1988` 부근):
$$\text{Linear Velocity} = 0.0, \quad \text{Angular Velocity} = 0.0$$
* $\text{CH5} < 1500$ (안누름 / `1012` 부근): 정상 주행 로직 실행
### 3.2 속도 제한 설정 (CH6)
* $\text{CH6} \ge 1500$ (`1832` 부근): $V_{\max} = 0.3 \text{ m/s}$ (저속 모드)
* $\text{CH6} < 1500$ (`1012~1090` 부근): $V_{\max} = 1.5 \text{ m/s}$ (고속 모드)
### 3.3 전후진 속도 계산 (CH2)
* **데드존(Deadband):** $1480 \le \text{CH2} \le 1520 \rightarrow V_x = 0.0$
* **후진 구간 ($1005 \le \text{CH2} < 1480$):**
$$V_x = -\left( \frac{1480 - \text{CH2}}{1480 - 1005} \right) \times V_{\max}$$
* **전진 구간 ($1520 < \text{CH2} \le 2000$):**
$$V_x = \left( \frac{\text{CH2} - 1520}{2000 - 1520} \right) \times V_{\max}$$
### 3.4 좌우 조향 각속도 계산 (CH1)
* **데드존(Deadband):** $1481 \le \text{CH1} \le 1503 \rightarrow \omega_z = 0.0$
* **좌회전 구간 ($1005 \le \text{CH1} < 1481$):**
$$\omega_z = -\left( \frac{1481 - \text{CH1}}{1481 - 1005} \right) \times \Omega_{\max}$$
* **우회전 구간 ($1503 < \text{CH1} \le 2000$):**
$$\omega_z = +\left( \frac{\text{CH1} - 1503}{2000 - 1503} \right) \times \Omega_{\max}$$
---
## 4. 구현 요구사항 (To Claude Code)
1. **기존 Xbox 입력 모듈 분리 및 교체:**
- 기존의 조이스틱 라이브러리(pygame, evdev, joy 노드 등) 종속성 제거
- 시리얼 통신(PySerial 등) 기반의 RC 수신기 데이터 리더 클래스 구현
2. **시리얼 패킷 디코딩 & Fail-safe 처리:**
- USB-to-TTL 시리얼 포트 오픈 및 예외 처리(재연결 로직)
- 패킷 타임아웃(예: 200ms 이상 신호 미수신 시) 발생 시 자동 감속 및 긴급 정지
3. **설정값 파라미터화 (Config):**
- Serial Port, Baudrate, 데드존 범위, 채널별 Max/Min/Center 값을 외부 설정(YAML, JSON, ROS 파라미터 등)으로 관리 가능하도록 모듈화
+236
View File
@@ -0,0 +1,236 @@
// HWT905-RS232 (WitMotion) IMU reader — Phase 1: pure observation, no control loop.
// Reads the sensor's active-push protocol (0x55-prefixed packets) over a plain
// serial port (RS232-over-USB, e.g. /dev/ttyUSB0) and prints accel/gyro/angle to
// stdout, optionally logging to CSV. Not Modbus — the RS485 variant uses Modbus,
// this RS232 variant does not.
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <fstream>
#include <iostream>
#include <string>
#include <termios.h>
#include <unistd.h>
namespace {
constexpr uint8_t kFrameHeader = 0x55;
constexpr uint8_t kTypeAccel = 0x51;
constexpr uint8_t kTypeGyro = 0x52;
constexpr uint8_t kTypeAngle = 0x53;
constexpr uint8_t kTypeMag = 0x54;
struct ImuState {
double accel[3] = {0, 0, 0}; // g
double gyro[3] = {0, 0, 0}; // deg/s
double angle[3] = {0, 0, 0}; // deg (roll, pitch, yaw)
double mag[3] = {0, 0, 0}; // raw counts
bool has_accel = false, has_gyro = false, has_angle = false, has_mag = false;
};
int16_t toInt16(uint8_t lo, uint8_t hi) {
return static_cast<int16_t>(static_cast<uint16_t>(lo) | (static_cast<uint16_t>(hi) << 8));
}
speed_t baudToSpeed(int baud) {
switch (baud) {
case 4800: return B4800;
case 9600: return B9600;
case 19200: return B19200;
case 38400: return B38400;
case 57600: return B57600;
case 115200: return B115200;
case 230400: return B230400;
default:
std::cerr << "[경고] 지원하지 않는 baud " << baud << ", 115200으로 대체\n";
return B115200;
}
}
int openSerialPort(const std::string &port, int baud) {
int fd = open(port.c_str(), O_RDWR | O_NOCTTY | O_NDELAY);
if (fd < 0) {
std::cerr << "[오류] 포트 열기 실패: " << port << " (" << std::strerror(errno) << ")\n";
return -1;
}
fcntl(fd, F_SETFL, 0); // switch back to blocking reads
struct termios options;
if (tcgetattr(fd, &options) != 0) {
std::cerr << "[오류] tcgetattr 실패\n";
close(fd);
return -1;
}
speed_t speed = baudToSpeed(baud);
cfsetispeed(&options, speed);
cfsetospeed(&options, speed);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB;
options.c_cflag &= ~CSTOPB;
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;
options.c_cflag &= ~CRTSCTS;
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
options.c_iflag &= ~(IXON | IXOFF | IXANY);
options.c_iflag &= ~(INLCR | ICRNL);
options.c_oflag &= ~OPOST;
options.c_cc[VMIN] = 1;
options.c_cc[VTIME] = 5; // 0.5s inter-byte timeout
tcflush(fd, TCIFLUSH);
if (tcsetattr(fd, TCSANOW, &options) != 0) {
std::cerr << "[오류] tcsetattr 실패\n";
close(fd);
return -1;
}
return fd;
}
// Parses one validated 11-byte WitMotion frame (frame[0]==0x55, checksum ok)
// into the running ImuState. Returns true if this frame completed an
// accel+gyro+angle group worth printing (i.e. it was an angle frame).
bool applyFrame(const uint8_t *frame, ImuState &state) {
const uint8_t type = frame[1];
switch (type) {
case kTypeAccel:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.accel[i] = raw / 32768.0 * 16.0; // g
}
state.has_accel = true;
return false;
case kTypeGyro:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.gyro[i] = raw / 32768.0 * 2000.0; // deg/s
}
state.has_gyro = true;
return false;
case kTypeAngle:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.angle[i] = raw / 32768.0 * 180.0; // deg
}
state.has_angle = true;
return true;
case kTypeMag:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.mag[i] = raw;
}
state.has_mag = true;
return false;
default:
return false; // time/quaternion/GPS frames etc. — ignored in Phase 1
}
}
} // namespace
int main(int argc, char **argv) {
std::setvbuf(stdout, nullptr, _IOLBF, 4096); // line-buffer even when piped
std::string port = "/dev/ttyUSB0";
int baud = 9600; // HWT905-232 factory default (confirmed against this unit)
std::string log_path;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--port" && i + 1 < argc) {
port = argv[++i];
} else if (arg == "--baud" && i + 1 < argc) {
baud = std::stoi(argv[++i]);
} else if (arg == "--log" && i + 1 < argc) {
log_path = argv[++i];
} else if (arg == "--help") {
std::cout << "사용법: " << argv[0]
<< " [--port /dev/ttyUSB0] [--baud 115200] [--log out.csv]\n";
return 0;
}
}
int fd = openSerialPort(port, baud);
if (fd < 0) return 1;
std::ofstream log_file;
if (!log_path.empty()) {
log_file.open(log_path);
if (!log_file) {
std::cerr << "[오류] 로그 파일 열기 실패: " << log_path << "\n";
return 1;
}
log_file << "t_s,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps,roll_deg,pitch_deg,yaw_deg\n";
}
std::cout << "포트 " << port << " @ " << baud << "bps 에서 IMU 데이터 수신 시작 "
<< "(Ctrl+C로 종료)\n";
std::cout.flush();
const auto t_start = std::chrono::steady_clock::now();
ImuState state;
uint8_t buf[11];
size_t buf_len = 0;
while (true) {
uint8_t byte;
ssize_t n = read(fd, &byte, 1);
if (n <= 0) {
if (n < 0 && errno != EAGAIN && errno != EINTR) {
std::cerr << "[오류] 시리얼 읽기 실패: " << std::strerror(errno) << "\n";
break;
}
continue;
}
if (buf_len == 0) {
if (byte != kFrameHeader) continue; // resync: wait for header
buf[buf_len++] = byte;
continue;
}
buf[buf_len++] = byte;
if (buf_len < 11) continue;
// Full 11-byte candidate frame collected — validate checksum.
uint8_t sum = 0;
for (int i = 0; i < 10; ++i) sum += buf[i];
if (sum != buf[10]) {
// Checksum mismatch: resync by sliding one byte and rescanning for 0x55.
std::cerr << "[경고] 체크섬 불일치, 프레임 폐기\n";
buf_len = 0;
continue;
}
bool print_now = applyFrame(buf, state);
buf_len = 0;
if (print_now && state.has_accel && state.has_gyro && state.has_angle) {
double t_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - t_start).count();
std::printf(
"t=%7.3fs accel[g]=(%+.3f,%+.3f,%+.3f) gyro[dps]=(%+7.2f,%+7.2f,%+7.2f) "
"angle[deg]=(roll=%+7.2f,pitch=%+7.2f,yaw=%+7.2f)\n",
t_s, state.accel[0], state.accel[1], state.accel[2], state.gyro[0], state.gyro[1],
state.gyro[2], state.angle[0], state.angle[1], state.angle[2]);
if (log_file) {
log_file << t_s << ',' << state.accel[0] << ',' << state.accel[1] << ','
<< state.accel[2] << ',' << state.gyro[0] << ',' << state.gyro[1] << ','
<< state.gyro[2] << ',' << state.angle[0] << ',' << state.angle[1] << ','
<< state.angle[2] << '\n';
log_file.flush();
}
}
}
close(fd);
return 0;
}
+79
View File
@@ -0,0 +1,79 @@
import serial
import time
# 확인된 포트 경로 설정
SERIAL_PORT = "/dev/cu.usbserial-0001"
BAUD_RATE = 420000
CRSF_SYNC = 0xC8
CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16
def parse_channels(payload):
if len(payload) < 22:
return []
# 22바이트의 채널 데이터를 정수로 변환 후 11비트씩 언패킹
data = int.from_bytes(payload[:22], byteorder='little')
channels = []
for _ in range(16):
raw_val = data & 0x07FF # 11-bit 마스크
# CRSF 원본 값(172~1811)을 RC 마이크로초(1000~2000µs) 단위로 변환
us_val = int((raw_val - 172) * (2000 - 1000) / (1811 - 172) + 1000)
channels.append(us_val)
data >>= 11
return channels
def main():
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=0.1)
print(f"Connected to {SERIAL_PORT} at {BAUD_RATE} baud.")
print("스틱과 스위치를 움직여 채널 값을 확인하세요. (종료: Ctrl+C)\n")
except Exception as e:
print(f"포트 연결 오류: {e}")
return
buf = bytearray()
while True:
try:
if ser.in_waiting:
buf.extend(ser.read(ser.in_waiting))
while len(buf) > 3:
if buf[0] != CRSF_SYNC:
buf.pop(0)
continue
length = buf[1]
total_packet_len = length + 2
if len(buf) < total_packet_len:
break # 전체 패킷 대기
packet = buf[:total_packet_len]
buf = buf[total_packet_len:]
frame_type = packet[2]
if frame_type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED:
payload = packet[3:-1] # CRC 제외 payload
channels = parse_channels(payload)
# read_crsf.py 출력 부분 수정
print(
f"\rCH1: {channels[0]:4d} | CH2: {channels[1]:4d} | "
f"CH3: {channels[2]:4d} | CH4: {channels[3]:4d} | "
f"CH5(SA/SC): {channels[4]:4d} | CH6(SD): {channels[5]:4d} | "
f"CH7: {channels[6]:4d} | CH8: {channels[7]:4d}",
end="", flush=True
)
except KeyboardInterrupt:
print("\n모니터링 종료")
break
except Exception as e:
print(f"\n데이터 수신 오류: {e}")
break
ser.close()
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,55 @@
RELEASE NOTES
=============
WITMOTION Turotial File
Package version: 2023.06.26
Release date: 2023-06-26
This package contains all necessary files for set-up of the sensor.
Unzip the .zip file and you will have a directory containing the files.
-----------
Website:
https://www.wit-motion.com/
Document download link:
Google Drive:
https://drive.google.com/open?id=1s-7NIhHXFyLhF8TmCFdrVPz80SwS3C1i
-----------
Youtube Channel:
https://www.youtube.com/c/WITMOTION
HWT905-RS232 Playlist:
https://youtube.com/playlist?list=PL43tdDrVL_VC49nv2WW4NphnUTxSkQ_cZ
-----------
After-sale Service& Technical Support:
If you have queries about the sensors' set-up, software installation, APP, drivers
or any other questions, suggestions, please feel free to contact us.
Our engineering team is committed to providing the required support necessary
to ensure that you are satisfied with WITMOTION sensor and the service.
1. Please find the below link of detailed contact method (support@wit-motion.com)
https://www.wit-motion.com/contacts/
2. Double-confirmed which platform you bought and
contact the corresponding team by the provided way
3. Sending a message including the problem and necessary description
would be highly helpful to give a workable solution with details
4. Leave a note indicating the platform you placed the order,
the order ID, so it is intuitive to check the model you purchase
and accessory you may buy
5. For a complicated issue like calibration, PIN connection, configurations,
it is recommended to provide necessary files including screenshots,
taking several pictures, or shooting videos. A short video including the PIN connection,
software setup, operation details would be preferred.
It would be further help for remote diagnostics.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
https://docs.clearpathrobotics.com/docs_robots/legacy/ros1_robots/outdoor_robots/husky/user_manual_husky
+18 -9
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# 1-Click Launch Script for C++ 4WD Motor Control with Mid-360S LiDAR Avoidance
PORT="${1:-/dev/ttyUSB0}"
# udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된 심볼릭
# 링크 사용 — ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름들은 고정이다.
PORT="${1:-/dev/ttyMOTOR}"
RC_PORT="${RC_PORT:-/dev/ttyRC}"
IMU_PORT="${IMU_PORT:-/dev/ttyIMU}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" || exit 1
@@ -10,10 +14,12 @@ echo "=================================================================="
echo " 🚀 ZLAC8015D 4WD + Livox Mid-360S 라이다 원클릭 런치 시스템"
echo "=================================================================="
# 1. USB Latency 1ms 단축
# 1. USB Latency 1ms 단축 (모터/RC/IMU 포트 전부)
if [ -f "./set_low_latency.sh" ]; then
echo "[1/2] USB 시리얼 포트($PORT) 지연 시간 1ms 최적화 적용 중..."
echo "[1/2] USB 시리얼 포트 지연 시간 1ms 최적화 적용 중... (모터: $PORT / RC: $RC_PORT / IMU: $IMU_PORT)"
./set_low_latency.sh "$PORT"
./set_low_latency.sh "$RC_PORT"
./set_low_latency.sh "$IMU_PORT"
fi
# 2. C++ 바이너리 존재 여부 확인 및 컴파일
@@ -23,14 +29,17 @@ if [ ! -f "./xbox_motor_control_cpp" ]; then
fi
echo "=================================================================="
echo " [시작] C++ 100Hz 초저지연 4WD 조이스틱 + 라이다 장애물 회피 시작 ($PORT)"
echo " - 라이다 기능 토글: Xbox 컨트롤러 X 버튼"
echo " - 정지/종료: B 버튼 또는 Ctrl+C"
echo " [시작] C++ 100Hz 초저지연 4WD RC(RadioMaster Pocket + XR1) + IMU(HWT905) + 라이다 장애물 회피 시작"
echo " - 모터 포트: $PORT / RC 수신기 포트: $RC_PORT / IMU 포트: $IMU_PORT"
echo " - 라이다 기능 비활성화: --no_lidar 옵션 / IMU 비활성화: --no_imu 옵션"
echo " - 매 주행마다 logs/에 CSV 로그 자동 저장 (끄려면 --no_log)"
echo " - 비상 정지: CH5 브레이크 스위치 또는 Ctrl+C"
echo "=================================================================="
# 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능)
# 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능. --rc_port/--imu_port를 다시
# 넘기면 아래 기본값을 덮어쓸 수 있다 — 인자 파싱은 뒤에 온 값이 우선 적용됨)
if [ $# -gt 1 ]; then
./xbox_motor_control_cpp --port "$PORT" "${@:2}"
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" --imu_port "$IMU_PORT" "${@:2}"
else
./xbox_motor_control_cpp --port "$PORT"
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" --imu_port "$IMU_PORT"
fi
+4 -1
View File
@@ -8,7 +8,10 @@ if [ ! -e "$PORT" ]; then
exit 1
fi
DEV_NAME=$(basename "$PORT")
# /dev/ttyMOTOR 같은 udev 고정 심볼릭 링크로 넘어올 수 있으므로, sysfs 조회에
# 필요한 실제 장치명(ttyUSB0 등)으로 반드시 풀어준다 — 안 그러면
# /sys/bus/usb-serial/devices/ttyMOTOR 경로가 존재하지 않아 항상 폴백으로 샌다.
DEV_NAME=$(basename "$(readlink -f "$PORT")")
LATENCY_PATH="/sys/bus/usb-serial/devices/$DEV_NAME/latency_timer"
if [ -f "$LATENCY_PATH" ]; then
+1291 -179
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.