← 回專案總覽與驗證系統

第二支手臂: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·m17HS4401 規格
減速比3.020T → 60T GT2 帶輪
關節可用扭矩1.35 N·m0.45 × 3
關節步距0.0375°1.8° ÷ 16 細分 ÷ 3
每關節轉一圈9600 步同上
肩帶 / 肘帶GT2-188 / GT2-40094T / 200T,標準閉環尺寸

皮帶長度是標準品,不是算出來想訂就訂的——所以流程反過來: 先挑得到的閉環皮帶長度,再由它反推馬達的安裝座標(MOT_X = 50.5 mm)。 中心距與皮帶長是可逆的一組式子,選了一邊另一邊就不自由。

A2 CAD 與 A3 URDF/MJCF

連桿質量備註
base_link540.4 g含 165×165 底板、轉盤軌道
turntable737.6 g含塔柱、兩顆馬達座
upper_arm96.3 g抽殼樑,J2→J3 140 mm
forearm93.5 gJ3→尖端 115 mm
合計 1467.8 g不含馬達與五金

關節極限不是手寫的。步進馬達自己沒有行程上限——極限完全由機構決定, 所以是把實體旋轉到干涉為止掃描出來的: j1 ±150°、j2 ±95°、j3 ±85°。A1 的驗證層直接比對 MJCF 的 jnt_range 與這份掃描結果,不允許任何一邊手動填。

A4 模擬:步進真正的失效模式

MJCF 裡刻意沒有建模失步——XML 的註解就這樣寫。原因是: 位置致動器飽和之後會追回來,那是伺服的行為;步進沒有回授,掉的步永遠回不來, 只有歸原點能清掉。那個不對稱是這兩台機器最本質的差別,它屬於模擬迴圈,不屬於 XML。

酬載肩峰值扭矩佔容量追蹤誤差結果
0 g0.210 N·m16%1.63°✓ 零失步
500 g1.044 N·m77%1.77°✓ 零失步
598 g—~100%—失步門檻
700 g1.584 N·m117%—✗ 塌到關節極限

過門檻之後是單向失控:一失步手臂就往下垂,力臂變長,需求扭矩再升, 失更多步。線性外推 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/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 g598 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,全部由真實檢查失敗抓到。