mechArm 270 Pi 動的追従の限界を探る Log#2 | 視覚サーボのループ周期を設計するために、アームの物理応答速度を測った
LOG183R2R2R2026/5/1

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.141

get_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 で送れるという事実は、物理側の律速を無視した話だった。「送れる速さ」と「動ける速さ」は、切り分けて測らなければ両方の意味が消える。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。