← 回專案總覽與驗證系統
第二支手臂:NEMA 17 步進(A1–A5)
第一支手臂用 SG90(廉價伺服)。第二支換成 42 型步進馬達, 不是為了做「更好的一支」,而是因為步進與伺服的失效方式根本不同—— 而那個差別正好是驗證系統最容易漏掉的東西。
A1 傳動方案:為什麼是遠端驅動 3:1 同步帶
NEMA 17(42×42×40 mm,280 g)比 SG90(9 g)重 31 倍。 直接把馬達裝在關節上,肩關節就得扛著肘關節的馬達揮動——所以馬達全部留在底座, 用 GT2 同步帶把扭矩送出去。
| 項目 | 數值 | 來源 |
|---|---|---|
| 馬達保持扭矩 | 0.45 N·m | 17HS4401 規格 |
| 減速比 | 3.0 | 20T → 60T GT2 帶輪 |
| 關節可用扭矩 | 1.35 N·m | 0.45 × 3 |
| 關節步距 | 0.0375° | 1.8° ÷ 16 細分 ÷ 3 |
| 每關節轉一圈 | 9600 步 | 同上 |
| 肩帶 / 肘帶 | GT2-188 / GT2-400 | 94T / 200T,標準閉環尺寸 |
皮帶長度是標準品,不是算出來想訂就訂的——所以流程反過來:
先挑得到的閉環皮帶長度,再由它反推馬達的安裝座標(MOT_X = 50.5 mm)。
中心距與皮帶長是可逆的一組式子,選了一邊另一邊就不自由。
A2 CAD 與 A3 URDF/MJCF
| 連桿 | 質量 | 備註 |
|---|---|---|
| base_link | 540.4 g | 含 165×165 底板、轉盤軌道 |
| turntable | 737.6 g | 含塔柱、兩顆馬達座 |
| upper_arm | 96.3 g | 抽殼樑,J2→J3 140 mm |
| forearm | 93.5 g | J3→尖端 115 mm |
| 合計 1467.8 g | 不含馬達與五金 | |
關節極限不是手寫的。步進馬達自己沒有行程上限——極限完全由機構決定,
所以是把實體旋轉到干涉為止掃描出來的:
j1 ±150°、j2 ±95°、j3 ±85°。A1 的驗證層直接比對 MJCF 的 jnt_range
與這份掃描結果,不允許任何一邊手動填。
A4 模擬:步進真正的失效模式
MJCF 裡刻意沒有建模失步——XML 的註解就這樣寫。原因是: 位置致動器飽和之後會追回來,那是伺服的行為;步進沒有回授,掉的步永遠回不來, 只有歸原點能清掉。那個不對稱是這兩台機器最本質的差別,它屬於模擬迴圈,不屬於 XML。
| 酬載 | 肩峰值扭矩 | 佔容量 | 追蹤誤差 | 結果 |
|---|---|---|---|---|
| 0 g | 0.210 N·m | 16% | 1.63° | ✓ 零失步 |
| 500 g | 1.044 N·m | 77% | 1.77° | ✓ 零失步 |
| 598 g | — | ~100% | — | 失步門檻 |
| 700 g | 1.584 N·m | 117% | — | ✗ 塌到關節極限 |
過門檻之後是單向失控:一失步手臂就往下垂,力臂變長,需求扭矩再升, 失更多步。線性外推 600 g 應該是 1.21 N·m,實測 1.396——多出來的就是這個正回饋。 700 g 以上終角直接停在 95.0°,也就是關節極限,整支塌到底。
順帶查出來的一件事:datasheet 轉速根本開不出來
16 細分 = 3200 步/馬達轉。AccelStepper 在 AVR 上大約 4000 步/s, 換算馬達 75 rpm——而 datasheet 寫 300 rpm。 拿 datasheet 轉速當規格,會做出一支韌體開不動的手臂。 所以步進模型裡的速度上限取「MCU 發得出來的」與「馬達轉得動的」較小者, 實際關節上限是 150°/s。要跑到 300 rpm 需要 16000 步/s,得換硬體計時器或降細分。
A5 韌體與差異測試:兩邊是不是同一台機器
SG90 那支的差異測試很容易:兩邊都寫角度,逐位元組比對位姿表就結束了。 步進手臂難在兩邊做的是不同的算術:
sim/stepper.py在 MuJoCo 的固定 dt 下用連續時間積分速度- AccelStepper 為每一步算一個間隔,MCU 睡到下一步,
而且把捨入誤差透過
_cn一路帶下去
所以「韌體與模擬相符」是一句必須量出來的宣稱。
sim/accelstepper.py 是 computeNewSpeed() 的逐行移植,
刻意不整理——那些彆扭的地方(0.676、_n 的正負號遊戲)在原始碼裡就是彆扭的,
差異測試的價值全在於第二個實作是獨立的,不是我們版本的改寫。
| 檢查 | 結果 |
|---|---|
| 以 avr-gcc 實際編譯 | Uno:flash 8448 B(26%)、RAM 465 B(22%) |
| 位姿表步數 = 模擬下達的步數 | 4 個位姿逐一相符 |
| 行程極限 = CAD 掃描值 | 三軸上下限全等 |
| 與理想梯形的偏差 | 短程 −8.0%、梯形轉折 +3.9%、長程收斂 +1.5% |
| 首步加速度倍率 | 1.823×(模型與移植實測相符) |
| 變異測試 | 11/11 |
發現:AccelStepper 的第一步不遵守 setAcceleration()
量測顯示每次移動的第一步等效加速度是設定值的 1.82 倍, 而且與設定值無關(2000 / 4000 / 8000 / 20000 / 50000 步/s² 全部量到 1.82×)。
這不是 bug,是 David Austin 的起步修正:c0 = 0.676·√(2/a)·1e6
比精確值短 32.4%,而第二個間隔是 0.6·c0,所以
a_eff = 1 / (0.6 × 0.676² × 2) = 1.823
推導值與量測值完全相符。(我第一次口算成 2.188,漏了第二個間隔的 0.6 因子—— 量測沒對上就是推導錯了,不是量測錯了。)
對結論的影響:原本模型把加速度夾在設定值, 等於每次移動都少算一次尖峰。補進模型後重算:
| 結論 | 夾在設定值 | 含 1.82× 首步 |
|---|---|---|
| 酬載失步門檻 | 598 g | 598 g(不變) |
| 零酬載可用斜坡上限 | ~400k 步/s² | ~250k 步/s² |
酬載門檻不變,因為首步衝量落在起始姿態,而峰值重力出現在移動中段—— 兩個尖峰不同時。但可用斜坡上限掉了 37%,那是實質的設計限制。
#41 產生器把逗號接在 // 註解後面,整個分隔符被註解掉 已修正
陣列初始化長這樣:
AccelStepper(AccelStepper::DRIVER, 2, 5) // j1_base_yaw, AccelStepper(AccelStepper::DRIVER, 3, 6) // j2_shoulder,
逗號在註解裡,C++ 看到的是三個相鄰的建構子沒有分隔符。
讀起來完全正常——我用 ",\n".join(...),
每個元素結尾剛好是註解。
只有真的跑 arduino-cli 才會知道。所以 A5 的第一條檢查不是比對字串, 是實際呼叫 avr-gcc 編譯,並把 flash / RAM 用量報出來而不是「希望夠用」。 RAM 那條額外檢查存在的理由:POSE 表放 PROGMEM, 哪天忘了加,堆疊會在執行期靜默撞上全域變數,而編譯照樣過。
#42 把 run() 的回傳值當成「有沒有踏一步」靜默 已修正
量 AccelStepper 的加速度時,得到 3118617 步/s²(設定值 8000), 而且四種不同行程量到完全相同的數字。相同的數字就是量測假象的指紋。
根因:AccelStepper 的 run() 回傳的是「還在動嗎」,不是「踏了一步」。
我的量測迴圈把每個 20 µs 取樣 tick 都當成一步收進去,44489 筆裡位置根本沒動,
兩個相隔 20 µs 的 tick 之間算出的「加速度」自然是垃圾。
修法:只在 currentPosition() 真的改變時才記錄。
修完中位數 7800、p99 8000(設定 8000),最大 24288 —— 那個最大值才是真的,
就是上面那個 1.82× 的首步。
#43 我又差點挑了一個「恰好能過」的門檻 已修正
A5 原本寫「AccelStepper 與連續模型的移動時間差 ≤ 3%」,三個行程量下來 1600 步 +2.0%、800 步 +0.1%、2400 步 +3.9%——失敗。
把門檻改成 4% 就會過。那正是 #34 記錄過的錯誤:挑一個恰好能過的門檻, 就是把問題藏起來。
正確做法是去刻畫它。掃 12 個行程長度後,偏差不是一個常數而是一個形狀:
| 行程 | 偏差 | 成因 |
|---|---|---|
| 100 步 | −8.0% | 0.676 起步修正讓短程偏快 |
| 800 步 | −0.1% | 過零點 |
| 2400 步 | +3.9% | 三角/梯形轉折處 |
| 9600 步 | +1.5% | 長程收斂 |
所以檢查改成兩條:偏差有界(≤10%,實測最壞 8.0%), 以及偏差不隨行程增長。第二條是關鍵——有界不夠, 若偏差隨行程單調增大,長行程遲早會失準。 另外補上一條最強的差異檢查:模型裡那個 1.823 常數, 直接從移植的演算法量回來對照。
A4 的代價:連續四個「會跑、會印數字」的錯模型
模擬是這整個專案裡最容易產出自信廢話的一層——沒有任何外部東西會反駁它。 下面每一個都通過了「程式跑得動而且印出合理的表」,而且是依序被發現的, 每修好一個才看得見下一個。
#35 「信念誤差」量到的是迴轉暫態 靜默 已修正
第一次跑出來的表:失步 0,信念誤差 89.40° / 59.40° / 59.40°。 零失步的開環步進,韌體的信念必然等於現實——59° 的信念誤差是不可能的。
原因:我把韌體的信念寫成「最終目標」,所以指令一下、軸還在轉的時候就被記成誤差。 那三個數字剛好是各軸的整段行程(89.4°≈90°、59.4°≈60°),就是初始步階響應。
修法:信念改成「驅動器已發出的步數」。真實韌體知道自己發了多少步,
不是知道終點。這樣 信念 − 現實 ≡ 失步,而驗證層新增的檢查就是直接驗這個恆等式。
#36 用致動器飽和當失步判準 → 3% 負載報 42752 步失步 靜默 已修正
修好 #35 後改用「致動器是否貼在力上限」判斷失步。結果肘部在 3% 扭矩容量下報出 33456 步失步。3% 負載不可能失步。
根因:位置致動器在每一次階躍指令的第一個 tick 都會飽和 (kp=43,誤差 1 rad → 43 N·m,遠超 0.45)。那是控制器撞到自己的限制, 不是馬達掉磁極。兩件事在報表上長得一模一樣。
修法:判準改回物理量——需求扭矩(重力 + 慣性×加速度)對比保持扭矩。 同時補上真正缺的東西:梯形加減速。對步進下階躍指令等於要求無限加速度, 真實韌體(AccelStepper)不會這樣做,所以那個「停轉」是我自己的軌跡造成的,不是手臂的問題。
#37 加速度用「未截斷的速度誤差」算,第一個 tick 就 2600 rad/s² 靜默 已修正
加上斜坡之後,1% 負載還是失步。追進去發現我把「還沒達成的速度差 ÷ dt」當成加速度,
而不是「實際施加的那一份」。第一個 tick 算出 2600 rad/s²,
乘上慣量就超過保持扭矩。修法:用截斷後的 dv_applied / dt。
一行之差,而兩個版本印出的表都「看起來合理」。
#38 MuJoCo 的 gear 是馬達端單位,我一直餵關節角 靜默 已修正
這個最隱蔽。MuJoCo 的傳動是 length = gear × qpos,
所以 ctrl 和 kp 都在馬達端。
我把關節角度直接寫進 ctrl,每個關節就停在指令的三分之一。
證據一直印在表上,只是我沒讀出來:肘部命令 −60°,終角剛好 −20.0°。 那個「剛好」是整除關係,不是巧合。
修法:kp 與 ctrlrange 全改馬達端
(kp = 保持扭矩 ÷ 一個馬達整步,關節剛度 gear²·kp = 128.9 N·m/rad),
迴圈寫入時乘上 gear。修完終角是 90.0° / 0.0° / −60.0°,
而且肩部空載 0.21 N·m 與手算的 0.20 N·m 對上了。
#39 kv 的量綱是混的,害步進落後 11° 靜默 已修正
原式 KV = 2√(KP × 0.02),那個 0.02 是我當初隨手當「關節慣量」塞的,
卻被套在馬達端增益上。結果迴路過阻尼約 6 倍,每次斜坡都落後 11°。
步進的轉子是被磁場鎖住的:滿扭矩時負載角只有一個整步(關節端 0.60°),不可能落後 11°。
修法:阻尼改由模型實測的慣量推導——建置時先產生一次 MJCF、載入、 掃過各關節行程取質量矩陣對角線最大值,再算臨界阻尼。追蹤誤差降到 0.56–2.30°。
#40 兩個「假通過」:蛻變檢查跟自己比,黃金基準看不見真實變更 靜默 已修正
(a) dataclass 預設值陷阱。我寫了一條蛻變檢查「斜坡放緩 → 失步不增」,
做法是 Stepper.max_accel_steps = 原值 ÷ 4。
但 dataclass 的預設值在類別建立時就被烘進 __init__ 簽章,
事後改類別屬性對新實例毫無作用——那條檢查是拿同一組參數跑兩次然後比較,
永遠通過,什麼都沒測。修法:參數改用傳參,並在檢查裡順便斷言兩次參數確實不同。
(b) 黃金容差寬到失去意義。把 j4 索引角從 30° 改成 34.29° 之後, T7 黃金回歸18/18 全過。但實際上酬載舉高變了 0.62 mm、搬運距離變了 1.23 mm—— 真的變了,只是容差(3 mm / 4 mm)吸收得掉。
於是去量「同組態重跑的離散度」:MuJoCo 對同一輸入是位元級決定性的, 5 次重跑離散度全是 0.000000。也就是說那些容差不是在吸收雜訊, 它只是讓真實幾何變更溜過去。
修法:容差改成儲存時捨入量級的 10 倍(0.001°/1e-5 N·m/0.01 mm)。 驗證方式是把索引角退回 1.75 齒——T7 現在失敗 15 項,而不是之前的全過。 一個看不見 4.29° 幾何變更的黃金回歸,等於沒有釘住任何東西。
變異測試:這些檢查真的抓得到嗎
A4 的驗證層一次就 21/21 全過。在連續錯四個模型之後,我不信任這個結果, 所以把上面每一個錯模型都重新植回去,看套件會不會失敗。
第一次跑出 9/9「全部抓到」——但九個全是 AttributeError。
那不是套件抓到變異,是我的變異腳本自己壞了(Check 是 dataclass 不是 dict)。
修掉之後真實結果是 8/9。
唯一漏掉的是「需求扭矩忽略慣性項」——因為在標稱斜坡(8000 步/s²)下慣性項只佔容量 2.9%,套件裡沒有任何情境能分辨兩者。這不該用放寬容差搪塞, 所以補了一個慣性真的會主導的情境,順便回答一個實際的設計問題: 斜坡開到 4×10⁵ 步/s²,零酬載也會失步——那時馬達撐不住的是慣性,不是重力。 補上之後 9/9,全部由真實檢查失敗抓到。