mechArm 270 Pi 動的追従の限界を探る Log#2 | 視覚サーボのループ周期を設計するために、アームの物理応答速度を測った
視覚サーボを動かすには、ループ周期を設計しなければならない。ループ周期を設計するには、「アームが実際に何 Hz まで動けるか」を知る必要がある。前回、ソフトウェアの送信速度は 91 Hz まで出ることがわかった。今回はその隣の問い——物理側の速度——を測った。
💡 Key Takeaway
視覚サーボのループ周期設計に必要な「物理応答速度」を測るため、wait_ms スイープを試みた。しかし測定行為そのものが制御ループを 100〜700ms 停止させることが判明し、設計を破棄した。is_moving() ベースの新設計で振幅 vs 動作完了時間テーブルを取得。振幅 10° で約 500ms(2Hz)、ソフトウェア律速 91Hz との差は 45 倍だった。
発生した問題 | 現場判断 | 結果 |
|---|---|---|
wait_ms スイープで ±2° の追従限界が見えなかった | 振幅を ±10° に拡大して再計測 | 数値が不整合(tracking_rate が単調でない、max_error=180°)。CSV 解析で根本原因を調査 |
CSV 解析で「測定行為が制御ループを破壊している」と判明 | wait_ms スイープ設計を破棄。is_moving() ベースの新設計に切り替えた | 振幅 vs 動作完了時間テーブルを取得。視覚サーボ設計に直接使える数字が出た |
is_moving() が一部試行で早すぎる False を返した | 外れ値として記録し、全体の傾向(単調増加)は有効と判断 | pymycobot の挙動として文書化。データの信頼性をメタに示せる知見として残した |
なぜ物理応答速度を測る必要があるのか
視覚サーボの設計はこういう構造だ。
カメラで位置取得 → 指令角度を計算 → send_angles で送信 → アームが動く
↑_____________________________________________________|
ループを繰り返すこのループを高速に回せば、アームはカメラの動きに追従できる。しかし「高速」とはどのくらいか。ループ周期を決めるには、2 つの数字が必要だ。
1 つ目は「ソフトウェアが命令を送れる速度」。前回(Log#1)で測った。Pi 上で set_fresh_mode(1) を有効にすると 91 Hz、Mac 経由でも 76 Hz が出た。
2 つ目は「アームが命令を受け取って実際に動ける速度」。これをまだ測っていなかった。
命令を 91 Hz で送れても、アームが 2 Hz でしか動けないなら、ループ周期は 2 Hz で設計しなければならない。今回測ったのは、この 2 つ目の数字だ。
最初の設計:wait_ms スイープ
「送信間隔(wait_ms)を変えながら追従率を測れば、限界がわかるはず」という仮説で始めた。
j0 関節を ±2° で往復させ、10 イテレーションに 1 回 get_angles を呼んで実測値を記録する。wait_ms を [0, 5, 10, 20, 50, 100, 200] ms でスイープして、追従率(指令値との誤差が ±5° 以内に収まる割合)が落ちる境界を探す設計だ。スクリプト名は fresh_mode_sweep_pi.py。
±2° では全条件で追従率 100% だった。振幅を ±10° に拡大して再実行した。
実行ログ(--all --amplitude 10.0):
[sweep] initializing MyCobot on /dev/ttyAMA0 @ 1000000
[sweep] MyCobot ready
[sweep] wait_ms=0: starting
=== Sweep wait_ms=0 Summary ===
Iterations : 100
Mean Hz : 36.5
Measured : 10/100 (10 samples)
Tracking rate: 80.0 % (threshold ±5.0 deg)
Mean error : 2.9 deg
Max error : 9.8 deg
[sweep] wait_ms=5: starting
=== Sweep wait_ms=5 Summary ===
Iterations : 100
Mean Hz : 19.3
Measured : 6/100 (6 samples)
Tracking rate: 83.3 % (threshold ±5.0 deg)
Mean error : 2.7 deg
Max error : 7.2 deg
[sweep] wait_ms=10: starting
=== Sweep wait_ms=10 Summary ===
Iterations : 100
Mean Hz : 18.5
Measured : 6/100 (6 samples)
Tracking rate: 50.0 % (threshold ±5.0 deg)
Mean error : 33.5 deg
Max error : 180.0 deg
[sweep] wait_ms=20: starting
=== Sweep wait_ms=20 Summary ===
Iterations : 100
Mean Hz : 16.6
Measured : 7/100 (7 samples)
Tracking rate: 57.1 % (threshold ±5.0 deg)
Mean error : 28.9 deg
Max error : 180.0 deg
[sweep] wait_ms=50: starting
=== Sweep wait_ms=50 Summary ===
Iterations : 100
Mean Hz : 11.5
Measured : 7/100 (7 samples)
Tracking rate: 42.9 % (threshold ±5.0 deg)
Mean error : 5.3 deg
Max error : 9.7 deg
[sweep] wait_ms=100: starting
=== Sweep wait_ms=100 Summary ===
Iterations : 100
Mean Hz : 7.4
Measured : 9/100 (9 samples)
Tracking rate: 55.6 % (threshold ±5.0 deg)
Mean error : 24.1 deg
Max error : 180.0 deg
[sweep] wait_ms=200: starting
=== Sweep wait_ms=200 Summary ===
Iterations : 100
Mean Hz : 4.4
Measured : 9/100 (9 samples)
Tracking rate: 55.6 % (threshold ±5.0 deg)
Mean error : 4.0 deg
Max error : 9.0 deg
=== All Conditions Summary (amplitude ±10.0 deg) ===
wait_ms | mean_hz | tracking_rate | mean_error | max_error
0 | 36.5 | 80.0 % | 2.9 deg | 9.8 deg
5 | 19.3 | 83.3 % | 2.7 deg | 7.2 deg
10 | 18.5 | 50.0 % | 33.5 deg | 180.0 deg
20 | 16.6 | 57.1 % | 28.9 deg | 180.0 deg
50 | 11.5 | 42.9 % | 5.3 deg | 9.7 deg
100 | 7.4 | 55.6 % | 24.1 deg | 180.0 deg
200 | 4.4 | 55.6 % | 4.0 deg | 9.0 deg物理的に解釈できない結果だった。wait_ms=0(36.5Hz)より wait_ms=5(83.3%)の方が追従率が高い。wait_ms=200(5Hz)でも 55.6% しかない。max_error=180° が複数条件に出る。tracking_rate が wait_ms に対して単調でなくランダムに見える。
設計の破綻:CSV 詳細ログを読んで気づいたこと
wait_ms=10 の詳細ログを開いた。計測行(i % 10 == 0)前後の t_send_ms を抜き出す。
wait_ms,iter,target_deg,measured_deg,error_deg,tracked,t_send_ms,t_after_send_ms
10,0,0,0.170,0.170,1,1146362.239,1146372.113
10,1,10.000,,,,1146524.286,1146536.041
10,2,0,,,,1146546.138,1146556.039
10,3,10.000,,,,1146566.137,1146576.042
10,4,0,,,,1146586.143,1146596.053
10,5,10.000,,,,1146606.185,1146616.047
10,6,0,,,,1146626.151,1146636.069
10,7,10.000,,,,1146646.167,1146656.037
10,8,0,,,,1146666.129,1146676.038
10,9,10.000,,,,1146686.136,1146696.040
10,10,0,6.760,6.760,0,1146706.137,1146716.048
10,11,10.000,,,,1146901.799,1146912.061 ← iter=10→11 で 195ms ジャンプ
10,20,0,4.130,4.130,1,1147090.146,1147100.046
10,21,10.000,,,,1147313.525,1147324.053 ← iter=20→21 で 223ms ジャンプ
10,30,0,0.350,0.350,1,1147494.145,1147504.047
10,31,10.000,,,,1147592.809,1147604.048 ← iter=30→31 で 98ms ジャンプ
10,40,0,,,,1147774.130,1147784.041 ← get_angles 失敗(measured 空欄)
10,41,10.000,,,,1148444.455,1148456.078 ← iter=40→41 で 670ms ジャンプ
10,50,0,,,,1148626.127,1148636.038 ← get_angles 失敗
10,51,10.000,,,,1149281.104,1149292.053 ← iter=50→51 で 655ms ジャンプ
10,60,0,,,,1149462.138,1149472.038 ← get_angles 失敗
10,61,10.000,,,,1150075.345,1150084.079 ← iter=60→61 で 613ms ジャンプ
10,70,0,,,,1150254.144,1150264.045 ← get_angles 失敗
10,71,10.000,,,,1150876.468,1150888.047 ← iter=70→71 で 622ms ジャンプ
10,80,0,180.000,180.000,0,1151058.139,1151068.048 ← 180° 異常値
10,81,10.000,,,,1151323.255,1151336.093 ← iter=80→81 で 265ms ジャンプ
10,90,0,9.580,9.580,0,1151506.227,1151516.141get_angles を呼ぶたびに次の send_angles まで 98〜670ms の空白が発生している。wait_ms=10ms のはずのループが、計測行の直後だけ最大 67 倍の時間がかかっている。
設計に 3 つの根本的な欠陥があった。
欠陥 1:計測タイミングが常に target=HOME のときしか測れない。 i % 10 == 0 は偶数インデックスなので、常に target=0°(HOME)のときに計測が発生する。target=+10° 方向への追従を一度も測っていなかった。
欠陥 2:get_angles が制御ループを破壊する。 send_angles と get_angles は同じシリアルバスを使う。get_angles の呼び出し時間がそのままループ周期に上乗せされる。wait_ms=0 でも mean_hz=36.5 にとどまっていた原因はこれだ(Log#1 で同条件 91Hz が出たのに)。
欠陥 3:180° はセンサ異常値。 pymycobot が稀に不正な角度値を返す既知の問題(Log#1 でも確認済み)。これが tracking_rate と max_error を歪めていた。
測定行為そのものが、測定対象を変えていた。この設計では「wait_ms の影響」ではなく「get_angles の遅延」を測っていたことになる。設計を捨てた。
新しい設計:is_moving() ベースの動作完了時間計測
問いを再定義した。「何 Hz まで追従できるか」ではなく、「振幅 N° のとき、1 回の動作完了に何 ms かかるか」。
is_moving() API を使えば、制御ループを干渉せずに計測できる。指令を 1 回送り、完了を待つ。前の設計のように「制御ループ中に割り込む」構造ではない。send_angles と計測の間にシリアルバスの競合がない。
motion_completion_pi.py 全文
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
motion_completion_pi.py
=======================
Pi 上で直接 MyCobot を叩き、is_moving() API を使って
「振幅 N° のときアームが動作完了するまでの時間」を測定する。
設計方針:
- fresh_mode は使わない(動作完了を待つ設計なので逆効果)。
- HOME → HOME+amp の動作を送信し、is_moving() を 5ms 間隔でポーリングして
False になった瞬間の経過時間を記録する。
- 制御ループから計測を分離する設計で、視覚サーボのループ周期設計に
直接使える数字を出す。
振幅スイープ: [1, 2, 5, 10, 20, 45, 90] deg × 5 試行
使い方:
python3 motion_completion_pi.py --all
python3 motion_completion_pi.py --amplitude 10
python3 motion_completion_pi.py --all --trials 10
python3 motion_completion_pi.py --dry-run
"""
import sys
import os
import csv
import time
import signal
import argparse
import math
SERIAL_PORT = "/dev/ttyAMA0"
BAUDRATE = 1000000
HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20
INIT_SLEEP = 0.5
RESULTS_DIR = os.path.expanduser("~/r2r2r-scripts/results")
TARGET_JOINT = 0
AMPLITUDES_DEG = [1, 2, 5, 10, 20, 45, 90]
N_TRIALS = 5
TIMEOUT_SEC = 5.0
POLL_INTERVAL_SEC = 0.005
START_TIMEOUT_SEC = 0.2
HOME_SETTLE_SEC = 1.5
mc = None
def log(msg):
print(f"[motion] {msg}", file=sys.stderr, flush=True)
def cleanup():
global mc
if mc is not None:
try:
mc.send_angles(HOME, SPEED)
log("HOME sent on exit")
except Exception as e:
log(f"cleanup HOME failed: {e}")
def handle_signal(signum, frame):
log(f"signal {signum} received, cleaning up")
cleanup()
sys.exit(0)
def ensure_results_dir():
os.makedirs(RESULTS_DIR, exist_ok=True)
def measure_one(amp_deg):
global mc
mc.send_angles(HOME, SPEED)
time.sleep(HOME_SETTLE_SEC)
target_angles = list(HOME)
target_angles[TARGET_JOINT] = HOME[TARGET_JOINT] + amp_deg
t_start = time.perf_counter()
mc.send_angles(target_angles, SPEED)
# フェーズ 1: is_moving() が True になるのを待つ
started = False
while time.perf_counter() - t_start < START_TIMEOUT_SEC:
if mc.is_moving():
started = True
break
time.sleep(POLL_INTERVAL_SEC)
if not started:
return {
"completion_ms": None,
"started": False,
"timed_out": False,
"note": "is_moving=True never detected within START_TIMEOUT_SEC",
}
# フェーズ 2: is_moving() が False になるのを待つ
while time.perf_counter() - t_start < TIMEOUT_SEC:
if not mc.is_moving():
completion = (time.perf_counter() - t_start) * 1000.0
return {
"completion_ms": completion,
"started": True,
"timed_out": False,
"note": "",
}
time.sleep(POLL_INTERVAL_SEC)
return {
"completion_ms": None,
"started": True,
"timed_out": True,
"note": f"timed out after {TIMEOUT_SEC}s",
}
def run_amplitude(amp_deg, n_trials):
print(f"\n[motion] amplitude={amp_deg} deg, trials={n_trials}")
rows = []
for trial in range(n_trials):
result = measure_one(amp_deg)
result["amplitude_deg"] = amp_deg
result["trial"] = trial
rows.append(result)
if result["completion_ms"] is not None:
print(f" trial {trial}: completion={result['completion_ms']:.1f} ms")
else:
print(f" trial {trial}: FAILED ({result['note']})")
return rows
def save_detail_csv(all_rows):
csv_path = os.path.join(RESULTS_DIR, "day183_motion_completion_detail.csv")
fieldnames = ["amplitude_deg", "trial", "completion_ms", "started", "timed_out", "note"]
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in all_rows:
out = {}
for k in fieldnames:
v = row.get(k)
if v is None:
out[k] = ""
elif isinstance(v, float):
out[k] = f"{v:.1f}"
elif isinstance(v, bool):
out[k] = str(v)
else:
out[k] = v
writer.writerow(out)
log(f"detail CSV saved: {csv_path}")
def compute_summary(all_rows):
amp_set = sorted(set(r["amplitude_deg"] for r in all_rows))
summaries = []
for amp in amp_set:
trials = [r for r in all_rows if r["amplitude_deg"] == amp]
n_trials = len(trials)
successes = [r for r in trials if r["completion_ms"] is not None]
n_success = len(successes)
if n_success > 0:
vals = [r["completion_ms"] for r in successes]
mean_ms = sum(vals) / len(vals)
min_ms = min(vals)
max_ms = max(vals)
if len(vals) > 1:
variance = sum((x - mean_ms) ** 2 for x in vals) / len(vals)
std_ms = math.sqrt(variance)
else:
std_ms = 0.0
else:
mean_ms = min_ms = max_ms = std_ms = None
summaries.append({
"amplitude_deg": amp,
"n_trials": n_trials,
"n_success": n_success,
"mean_ms": mean_ms,
"std_ms": std_ms,
"min_ms": min_ms,
"max_ms": max_ms,
})
return summaries
def save_summary_csv(summaries):
csv_path = os.path.join(RESULTS_DIR, "day183_motion_completion_summary.csv")
fieldnames = ["amplitude_deg", "n_trials", "n_success", "mean_ms", "std_ms", "min_ms", "max_ms"]
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for s in summaries:
out = {}
for k in fieldnames:
v = s[k]
if v is None:
out[k] = ""
elif isinstance(v, float):
out[k] = f"{v:.1f}"
else:
out[k] = v
writer.writerow(out)
log(f"summary CSV saved: {csv_path}")
def print_summary_table(summaries):
print("\n=== Motion Completion Summary ===")
print(f"{'amplitude':>9} | {'n_success/n_trials':>18} | {'mean_ms':>7} | {'std_ms':>6} | {'min_ms':>6} | {'max_ms':>6}")
for s in summaries:
n_str = f"{s['n_success']}/{s['n_trials']}"
if s["mean_ms"] is not None:
print(f"{s['amplitude_deg']:>9} | {n_str:>18} | {s['mean_ms']:>7.1f} | {s['std_ms']:>6.1f} | {s['min_ms']:>6.1f} | {s['max_ms']:>6.1f}")
else:
print(f"{s['amplitude_deg']:>9} | {n_str:>18} | N/A | N/A | N/A | N/A")
print(f"=================================\n")
def main():
global mc
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--amplitude", type=float)
group.add_argument("--all", action="store_true")
group.add_argument("--dry-run", action="store_true")
parser.add_argument("--trials", type=int, default=N_TRIALS)
args = parser.parse_args()
if args.dry_run:
print("=== motion_completion_pi.py (dry-run) ===")
print(f"Serial : {SERIAL_PORT} @ {BAUDRATE}")
print(f"HOME : {HOME}")
print(f"Speed : {SPEED}")
print(f"Target joint : j{TARGET_JOINT}")
print(f"Amplitudes : {AMPLITUDES_DEG}")
print(f"Trials per amp: {args.trials}")
print(f"Timeout : {TIMEOUT_SEC} sec")
print(f"Poll interval : {POLL_INTERVAL_SEC * 1000:.0f} ms")
print(f"Start timeout : {START_TIMEOUT_SEC * 1000:.0f} ms")
print(f"Home settle : {HOME_SETTLE_SEC} sec")
return
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
log(f"initializing MyCobot on {SERIAL_PORT} @ {BAUDRATE}")
from pymycobot.mycobot import MyCobot
mc = MyCobot(SERIAL_PORT, BAUDRATE)
time.sleep(INIT_SLEEP)
log("MyCobot ready")
all_rows = []
try:
if args.amplitude is not None:
all_rows.extend(run_amplitude(args.amplitude, args.trials))
elif args.all:
for amp in AMPLITUDES_DEG:
all_rows.extend(run_amplitude(amp, args.trials))
finally:
cleanup()
if all_rows:
ensure_results_dir()
save_detail_csv(all_rows)
summaries = compute_summary(all_rows)
save_summary_csv(summaries)
print_summary_table(summaries)
log("measurement completed")
if __name__ == "__main__":
main()実機実行ログ(--all、全振幅)
[motion] initializing MyCobot on /dev/ttyAMA0 @ 1000000
[motion] MyCobot ready
[motion] amplitude=1 deg, trials=5
trial 0: completion=126.8 ms
trial 1: completion=97.6 ms
trial 2: FAILED (is_moving=True never detected within START_TIMEOUT_SEC)
trial 3: completion=139.2 ms
trial 4: completion=217.4 ms
[motion] amplitude=2 deg, trials=5
trial 0: completion=335.7 ms
trial 1: completion=227.9 ms
trial 2: completion=325.2 ms
trial 3: completion=228.7 ms
trial 4: completion=416.8 ms
[motion] amplitude=5 deg, trials=5
trial 0: completion=338.8 ms
trial 1: completion=444.7 ms
trial 2: completion=336.8 ms
trial 3: completion=327.4 ms
trial 4: completion=351.1 ms
[motion] amplitude=10 deg, trials=5
trial 0: completion=575.5 ms
trial 1: completion=479.3 ms
trial 2: completion=485.2 ms
trial 3: completion=487.0 ms
trial 4: completion=568.6 ms
[motion] amplitude=20 deg, trials=5
trial 0: completion=726.4 ms
trial 1: completion=765.8 ms
trial 2: completion=739.6 ms
trial 3: completion=787.9 ms
trial 4: completion=805.8 ms
[motion] amplitude=45 deg, trials=5
trial 0: completion=1319.7 ms
trial 1: completion=1266.9 ms
trial 2: completion=1263.7 ms
trial 3: completion=187.2 ms ← 異常値(他試行比 1/7 以下)
trial 4: completion=1890.1 ms
[motion] amplitude=90 deg, trials=5
trial 0: completion=2265.3 ms
trial 1: completion=2902.9 ms
trial 2: completion=2997.6 ms
trial 3: completion=802.1 ms ← 異常値(他試行比 1/3 以下)
trial 4: completion=2569.6 ms
[motion] HOME sent on exit
[motion] detail CSV saved: ~/r2r2r-scripts/results/day183_motion_completion_detail.csv
[motion] summary CSV saved: ~/r2r2r-scripts/results/day183_motion_completion_summary.csv
=== Motion Completion Summary ===
amplitude | n_success/n_trials | mean_ms | std_ms | min_ms | max_ms
1 | 4/5 | 145.2 | 44.3 | 97.6 | 217.4
2 | 5/5 | 306.9 | 71.6 | 227.9 | 416.8
5 | 5/5 | 359.8 | 43.1 | 327.4 | 444.7
10 | 5/5 | 519.1 | 43.3 | 479.3 | 575.5
20 | 5/5 | 765.1 | 29.4 | 726.4 | 805.8
45 | 5/5 | 1185.5 | 552.1 | 187.2 | 1890.1
90 | 5/5 | 2307.5 | 795.9 | 802.1 | 2997.6
=================================
[motion] measurement completed追加で 0.5°、15°、30°、60° の計測を実施し、11 振幅の統合データとした。
追加計測ログ:
[motion] amplitude=0.5 deg, trials=5
trial 0: completion=469.3 ms
trial 1: completion=77.2 ms
trial 2: completion=136.0 ms
trial 3: completion=106.9 ms
trial 4: completion=105.7 ms
[motion] amplitude=15 deg, trials=5
trial 0: completion=599.3 ms
trial 1: completion=655.6 ms
trial 2: completion=604.3 ms
trial 3: completion=601.5 ms
trial 4: completion=611.7 ms
[motion] amplitude=30 deg, trials=5
trial 0: completion=978.4 ms
trial 1: completion=988.5 ms
trial 2: completion=2436.9 ms ← 異常値
trial 3: completion=957.4 ms
trial 4: completion=942.1 ms
[motion] amplitude=60 deg, trials=5
trial 0: completion=1615.7 ms
trial 1: completion=1596.3 ms
trial 2: completion=1610.0 ms
trial 3: completion=1714.7 ms
trial 4: completion=1593.4 ms統合後の全データ(11 振幅・計 55 試行)
amplitude | n_success | mean_ms | std_ms | min_ms | max_ms
0.5 | 5/5 | 179.0 | 146.3 | 77.2 | 469.3
1 | 4/5 | 145.2 | 44.3 | 97.6 | 217.4
2 | 5/5 | 306.9 | 71.6 | 227.9 | 416.8
5 | 5/5 | 359.8 | 43.1 | 327.4 | 444.7
10 | 5/5 | 519.1 | 43.3 | 479.3 | 575.5
15 | 5/5 | 614.5 | 21.0 | 599.3 | 655.6
20 | 5/5 | 765.1 | 29.4 | 726.4 | 805.8
30 | 5/5 | 1260.6 | 588.3 | 942.1 | 2436.9
45 | 5/5 | 1185.5 | 552.1 | 187.2 | 1890.1
60 | 5/5 | 1626.0 | 45.1 | 1593.4 | 1714.7
90 | 5/5 | 2307.5 | 795.9 | 802.1 | 2997.6外れ値は計 3 件(amp=30 trial 2: 2436.9ms、amp=45 trial 3: 187.2ms、amp=90 trial 3: 802.1ms)。いずれも is_moving() が動作中に一時的に False を返したと考えられる。外れ値を除いた全体の傾向は単調増加だ。
視覚サーボ設計への含意
Log#1 と今回で取れた数字を並べる。
ソフトウェア律速(Log#1): 91 Hz = 11ms 周期
物理応答律速(今回):
振幅 2° → 307ms ≈ 3.3 Hz
振幅 5° → 360ms ≈ 2.8 Hz
振幅 10° → 519ms ≈ 1.9 Hz
振幅 20° → 765ms ≈ 1.3 Hz「5〜10 Hz で動的追従が成立するのではないか」という Log#1 の仮説は甘すぎた。振幅 10° で動作完了は 519ms(2Hz)かかる。91Hz で命令を送り続けても、アームが 1 サイクルを完了するまでに 47 回分の命令が積み上がる。
視覚サーボのループ周期はこう設計する。
視覚サーボ実用周期 = max(制御ループ最低周期, 動作完了時間)
= max(11ms, 519ms) ← 振幅 10° の場合
= 519ms ≈ 2 Hzこの数字が出たことで、次に設計すべき問いが変わった。「何 Hz で送るか」ではなく、「1 ループあたりの移動量(振幅)をどう制御するか」だ。振幅を小さく保てば、より高頻度なループが組める。視覚サーボの制御則の設計に入れる。
今回の実験から得た原則
2 つのことが手で確かめられた。
1 つ目。測定行為が測定対象を変える問題は、シリアル通信が 1 本しかない環境で特に起きやすい。get_angles を制御ループに混ぜることは、制御周期を計測しているのか、get_angles の遅延を計測しているのかが区別できない状態を作る。計測方法と計測対象の間の干渉を、設計前に洗い出す必要がある。
2 つ目。物理応答速度は振幅に依存する。91Hz で送れるという事実は、物理側の律速を無視した話だった。「送れる速さ」と「動ける速さ」は、切り分けて測らなければ両方の意味が消える。
