ロボットアームのテレメトリダッシュボードを作る Log#1 | ターミナルに流れて消えていた状態を、止めずに観る
LOG240R2R2R2026/6/16

ロボットアームのテレメトリダッシュボードを作る Log#1 | ターミナルに流れて消えていた状態を、止めずに観る

ロボットアームを動かすコードは、ずっとターミナルで動かしてきた。print が流れ、エラーが出て、また流れる。動いている間、アームが今どの段にいて、何秒そこに留まっているか、グリッパーは開いているか閉じているか——それはログの行として一瞬表示され、次の行に押し流されて消える。後から「さっきどこで止まった」を読み返そうとすると、スクロールバックの海を遡ることになる。

この状態を変える。制御ループは止めない。daemon も触らない。送信も増やさない。制御の脇に読み取り専用の観測層を立てて、流れて消える状態をブラウザで観られるようにする。テレメトリダッシュボードを作る。第一歩として、状態を投げる emit、最新値を配る observe_server、グランスとドリルインで観る HTML を作り、実機の Pick&Place がブラウザにライブで映るところまで進めた。

なぜターミナルではダメなのか

ターミナルは状態を「流す」。流れたものは消える。これが2つの不便になる。

まず、詰まりが見えない。アームがある姿勢へ降下しようとして、何秒そこで粘っているか——ターミナルは「降下開始」の行を1回出すだけで、経過時間を見せない。動いているのか固まっているのかが、ログの流れからは判別できない。

そして、失敗の反復が読めない。同じ理由で3回続けて掴み損ねたとき、その3回は時間方向に流れて散らばる。「同じ失敗を繰り返している」という、改善に一番効く情報が、行と行の間に埋もれる。

商用のロボット開発では、この問題はとっくに認識されている。Foxglove は「開発者はデバッグにターミナル(rosout)を使うが、商用や最終用途には向かない。オペレータは大量のデータストリームを追う時間がない」と書いている。テレメトリ・モニタリング・オブザーバビリティという言葉で呼ばれる領域がそれで、ターミナルから観測プラットフォームへ移る、というのが定石になっている。

この移行を、iPhone と Unity で実機を動かしてきた個人の延長として、自分の手で作る。それがこのシリーズの主題になる。

daemon から読む案は捨てた

最初に確かめたのは、状態をどこから読むかだった。mechArm は Pi 上の常駐 daemon(mecharm_daemon.py)が唯一のシリアル所有者で、Mac の制御スクリプトが TCP 5555 経由でコマンドを送る構成になっている。

素朴には「観測も daemon に2本目のクライアントとして繋いで、現在角度を polling すればいい」と考えた。だが daemon のソースを読んで、それができないことが分かった。該当箇所はこうなっている。

daemon の accept ループは単一クライアントを逐次処理する設計で、listen(1) のあと1本の接続を serve_client で掴むと、そのクライアントが切断するまで recv ループでブロックする。つまり制御スクリプトが繋いでいる間、観測クライアントの2本目は accept されない。一番見たい「運転中」に、観測は繋げない。

def main():
    global _server_sock

    signal.signal(signal.SIGTERM, shutdown_handler)
    signal.signal(signal.SIGINT, shutdown_handler)

    init_mycobot()

    _server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    _server_sock.bind((DAEMON_HOST, DAEMON_PORT))
    _server_sock.listen(1)
    log(f"ポート {DAEMON_PORT} で待ち受け中...")

    try:
        while _running:
            try:
                client_sock, addr = _server_sock.accept()
            except OSError:
                break
            serve_client(client_sock, addr)
    finally:
        if _mc is not None:
            try:
                _mc.set_fresh_mode(0)
                log("set_fresh_mode(0)")
            except Exception:
                pass
        _server_sock.close()
        log("シャットダウン完了")

さらに daemon が返せる状態は get_angles(6関節角)だけで、フェーズ・グリッパーの開閉・試行の文脈は持っていない。それらは制御ループの中にしか存在しない。

この2つの事実が、設計を一方向に決めた。観測の供給源は daemon ではなく、制御ループ自身にする。制御ループは送信のたびに新鮮な角度を既に読んでいて、フェーズもグリッパーの状態も手の中にある。観測はそれを脇のチャンネルに流し、別プロセスがそれを受けてブラウザに配る。daemon には一切触らない。

3つの層

作るものを3つに分けた。

ひとつめ、emit.py。制御ループから観測値を localhost の UDP 5601 へ「1 datagram = 1 JSON 行」で投げるだけの送信側。投げて忘れる(fire-and-forget)。例外を全て握り、制御を1ミリ秒も止めない。

ふたつめ、observe_server.py。UDP を受けて最新の1個だけ保持し、WebSocket でブラウザへ約100ミリ秒間隔で配る中継役(keeper)。

みっつめ、static/index.html。受け取った値を、ひと目で分かる「グランス」と、掘って見る「ドリルイン」の2層で描くブラウザUI。

供給源(emit)・中継(server)・表示(html)を分けた。送信側だけが制御ループに依存し、中継と表示はその下流で「アームの状態」という抽象だけを扱う。供給を差し替えれば、別の機種でも同じ中継・同じ表示が使える継ぎ目になる。今は mechArm が供給するが、後で別のアームが供給しても表示側は変わらない。

emit.py:投げて忘れる送信側

まず送信側の全文。ここが今回の心臓になる。ソケットはモジュールロードで1個だけ生成して使い回し、seq はプロセス内で単調増加、そして全ての例外を握って raise しない——制御ループがこの emit のせいで止まることが絶対にあってはならないため。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""observe/emit.py — UDP fire-and-forget テレメトリ送信

制御ループからの観測値を localhost UDP 5601 に「1 datagram = 1 JSON 行」で投げる。
**fire-and-forget・例外を全て握る・制御の振る舞いに絶対干渉しない**。

スキーマ(ステップ A の keeper 側と完全一致):
  {"t": <unix_ms:int>, "seq": <int>, "source": "control_loop"|...,
   "angles": [j1..j6]|null, "ee": [x,y,z]|null,
   "gripper": 0|1|null, "phase": <str>|null, "phase_t0": <ms>|null,
   "trial": {"id": <str>, "task": <str>}|null,
   "verdict": <dict>|null}

設計:
- ソケットはモジュールロードで 1 個生成して使い回す(毎回 open しない)
- seq はモジュール内グローバルで単調増加、emit ごとに +1
- どの例外も raise しない(制御を絶対に止めない)
- localhost のみ。外部公開しない
"""

from __future__ import print_function

import json
import socket
import time


_UDP_HOST = "127.0.0.1"
_UDP_PORT = 5601


# ソケットはモジュールロードで 1 個生成して使い回す。
# 失敗しても emit() は raise しない(None チェックで握る)。
# nonblocking: 送信バッファ満杯時に sendto がブロックして制御ループを止めないため。
# 取りこぼした datagram は次の emit で回復する(観測の最新主義)。
try:
    _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    _sock.setblocking(False)
except Exception:
    _sock = None


# seq はモジュール内グローバル。プロセス開始から単調増加。
# telemetry_probe.py との競合は source 識別子で見分ける運用。
_seq = 0


def emit(angles=None, phase=None, phase_t0_ms=None, gripper=None,
         ee=None, trial=None, verdict=None, source="control_loop"):
    """テレメトリ 1 発を UDP 5601 へ送る。

    全例外を握る・raise しない・制御を 1 ミリ秒も止めない。
    UDP 送信失敗・JSON 化失敗・ソケット未生成、どれでも例外を投げない。
    """
    global _seq
    try:
        _seq += 1
        payload = {
            "t": int(time.time() * 1000),
            "seq": _seq,
            "source": source,
            "angles": list(angles) if angles is not None else None,
            "ee": list(ee) if ee is not None else None,
            "gripper": gripper,
            "phase": phase,
            "phase_t0": phase_t0_ms,
            "trial": trial,
            "verdict": verdict,
        }
        if _sock is None:
            return
        data = (json.dumps(payload) + "\n").encode("utf-8")
        _sock.sendto(data, (_UDP_HOST, _UDP_PORT))
    except Exception:
        # 制御を絶対に止めない。失敗は黙って捨てる
        pass

スキーマには angles や phase だけでなく、ee・verdict も最初から枠として置いてある。今回これらは常に null で流れる。だが将来 EE 位置の算出や成否判定(VLM)を配線するとき、スキーマを変えずに値を埋めるだけで済む。空の席を先に予約しておく。

ソケットの setblocking(False) は、レビューを通したあとに加えた1行になる。localhost の小さな datagram なので送信がブロックする実害はほぼ無いが、「emit は絶対に制御タイミングへ遅延を足さない」を実害の大小ではなく構造で保証するために入れた。差分はこれだけ。

34a35,36
> # nonblocking: 送信バッファ満杯時に sendto がブロックして制御ループを止めないため。
> # 取りこぼした datagram は次の emit で回復する(観測の最新主義)。
36a39
>     _sock.setblocking(False)

受信側の不在・送信バッファの圧迫を想定し、5万連発のバースト送信でも例外が出ないことを確認している。

observe_server.py:最新の1個だけを配る中継

中継側の全文。UDP を非同期で受け続けて最新の1個だけを保持し、WebSocket で約100ミリ秒間隔でブラウザに配る。受信が無くても現在時刻と最後の値を送り続けるので、ブラウザ側は「固まり」を識別できる。全てのバインドは localhost に限定している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""observe_server.py — keeper: UDP 受信 → WebSocket push

UDP 5601 で telemetry datagram を受け、**最新 1 個だけ** 保持。
WebSocket /ws で接続中ブラウザに ~100ms 間隔で最新値を push。

設計の性質:
  - UDP 受信は fire-and-forget。古い datagram は捨てる(最新主義)
  - 受信が無くてもタイマで現在時刻+最後の値を送り、ブラウザが「固まり」を識別できる
  - 全バインド **localhost 限定**。外部公開しない
  - daemon を触らない・制御ループを触らない

依存(個別 pip):
  pip install fastapi uvicorn

CLI:
  python3 observe_server.py
  → http://127.0.0.1:8000/  で UI
"""

import asyncio
import json
import socket
import sys
import time
from pathlib import Path

try:
    from fastapi import FastAPI, WebSocket, WebSocketDisconnect
    from fastapi.responses import FileResponse, JSONResponse
    import uvicorn
except ImportError as e:
    print("[ERROR] 依存パッケージが無い: {}".format(e), file=sys.stderr)
    print("        pip install fastapi uvicorn", file=sys.stderr)
    sys.exit(1)


# ---- 定数 -------------------------------------------------------------------
HTTP_HOST = "127.0.0.1"
HTTP_PORT = 8000
UDP_LISTEN_HOST = "127.0.0.1"
UDP_LISTEN_PORT = 5601
WS_PUSH_INTERVAL_SEC = 0.1

_STATIC_DIR = Path(__file__).resolve().parent / "static"


# ---- 最新値ストア(プロセス内グローバル)-----------------------------------
_latest = {"datagram": None, "received_at_ms": None}


# ---- UDP listener(バックグラウンドタスク)---------------------------------
async def _udp_listener():
    """UDP 5601 を非同期で受信し続け、_latest を更新。
    例外は記録のみ。listener は落ちないことを優先する。"""
    loop = asyncio.get_event_loop()
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setblocking(False)
    try:
        sock.bind((UDP_LISTEN_HOST, UDP_LISTEN_PORT))
    except OSError as e:
        print("[SRV] UDP bind 失敗 {}:{}: {}".format(
            UDP_LISTEN_HOST, UDP_LISTEN_PORT, e), file=sys.stderr)
        return
    print("[SRV] UDP listen {}:{}".format(UDP_LISTEN_HOST, UDP_LISTEN_PORT))

    while True:
        try:
            data, _addr = await loop.sock_recvfrom(sock, 65535)
        except Exception as e:
            print("[SRV] UDP recv 例外: {}".format(e), file=sys.stderr)
            await asyncio.sleep(0.1)
            continue
        try:
            line = data.decode("utf-8").strip()
            payload = json.loads(line)
        except Exception as e:
            print("[SRV] UDP parse 例外: {}  raw={!r}".format(e, data[:200]),
                  file=sys.stderr)
            continue
        _latest["datagram"] = payload
        _latest["received_at_ms"] = int(time.time() * 1000)


# ---- FastAPI ----------------------------------------------------------------
app = FastAPI()


@app.on_event("startup")
async def _startup():
    asyncio.create_task(_udp_listener())


@app.get("/")
def index():
    html_path = _STATIC_DIR / "index.html"
    if not html_path.exists():
        return JSONResponse(
            status_code=500,
            content={"error": "static/index.html not found",
                     "path": str(html_path)})
    return FileResponse(str(html_path), media_type="text/html")


@app.get("/healthz")
def healthz():
    """ヘルスチェック。daemon/UDP 状況問わず常に 200 を返す(keeper 自身の生存)."""
    return {
        "ok": True,
        "server_now_ms": int(time.time() * 1000),
        "last_received_at_ms": _latest["received_at_ms"],
        "has_datagram": _latest["datagram"] is not None,
    }


@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
    await ws.accept()
    print("[SRV] WS connected")
    try:
        while True:
            now_ms = int(time.time() * 1000)
            msg = {
                "server_now_ms": now_ms,
                "datagram": _latest["datagram"],
                "received_at_ms": _latest["received_at_ms"],
            }
            await ws.send_text(json.dumps(msg))
            await asyncio.sleep(WS_PUSH_INTERVAL_SEC)
    except WebSocketDisconnect:
        print("[SRV] WS disconnected")
    except Exception as e:
        print("[SRV] WS 例外: {}".format(e), file=sys.stderr)


# ---- メイン -----------------------------------------------------------------
def main():
    print("=" * 60)
    print("[SRV] observe_server.py")
    print("[SRV] HTTP/WS = {}:{}".format(HTTP_HOST, HTTP_PORT))
    print("[SRV] UDP in  = {}:{}".format(UDP_LISTEN_HOST, UDP_LISTEN_PORT))
    print("[SRV] open    http://{}:{}/".format(HTTP_HOST, HTTP_PORT))
    print("[SRV] health  http://{}:{}/healthz".format(HTTP_HOST, HTTP_PORT))
    print("=" * 60)
    uvicorn.run(app, host=HTTP_HOST, port=HTTP_PORT, log_level="warning")


if __name__ == "__main__":
    main()

「最新の1個だけ保持」は意図的な選択になる。観測は履歴の完全性ではなく、今この瞬間の状態を見るためのもの。古い datagram は捨て、最新で上書きする。受信が止まっても WebSocket は時刻だけ更新して送り続けるので、ブラウザは「データが止まった」ことを時間差として検出できる。

index.html:グランスとドリルイン

表示側の全文。判断を変える4つ(フェーズと経過秒・グリッパー・J1角度・試行)を上段のグランスに大きく置き、関節6軸の生値・EE・成否判定・生datagramは下段のドリルインに畳んだ。配色はモノクロ、フォントは IBM Plex Mono と Noto Sans JP。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>observe — グランス+ドリルイン</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #f5f5f0;
    --fg: #0d0d0d;
    --mono: 'IBM Plex Mono', monospace;
    --sans: 'Noto Sans JP', sans-serif;
    --border: 1.5px solid var(--fg);
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: var(--bg);
    color: var(--fg);
    font-family: var(--mono);
    padding: 40px 16px;
    max-width: 960px;
    margin: 0 auto;
    transition: opacity 0.3s;
  }
  body.stale { opacity: 0.32; }

  .conn-bar {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    border-bottom: var(--border);
    padding-bottom: 10px;
    margin-bottom: 18px;
    font-size: 13px;
    letter-spacing: 0.08em;
  }
  .conn-bar .label { color: #888; }
  .conn-left { display: flex; align-items: center; gap: 8px; }
  .conn-dot {
    display: inline-block;
    width: 11px; height: 11px;
    background: var(--fg);
  }
  .conn-right { color: #888; }
  .conn-right b { color: var(--fg); font-weight: 600; }

  .glance {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 18px;
    margin-bottom: 18px;
  }
  .cell {
    border: var(--border);
    padding: 22px 24px;
    min-height: 190px;
    display: flex;
    flex-direction: column;
  }
  .cell .k {
    font-size: 13px;
    letter-spacing: 0.12em;
    color: #888;
    margin-bottom: 14px;
  }
  .big {
    font-size: 76px;
    font-weight: 700;
    line-height: 1.0;
    letter-spacing: -0.01em;
  }
  .big .unit { font-size: 26px; font-weight: 500; color: #888; margin-left: 4px; }
  .phase-name { font-size: 34px; font-weight: 600; margin-bottom: 8px; }
  .phase-elapsed { font-size: 60px; font-weight: 700; line-height: 1.0; }
  .phase-elapsed .unit { font-size: 22px; font-weight: 500; color: #888; }
  .grip {
    font-size: 60px;
    font-weight: 700;
    align-self: flex-start;
  }
  .grip.close {
    background: var(--fg);
    color: var(--bg);
    padding: 4px 18px;
  }
  .trial .row { font-size: 15px; margin-top: 6px; display: flex; gap: 14px; }
  .trial .row .tk { color: #888; min-width: 48px; }

  details.drill {
    border: var(--border);
    padding: 14px 18px;
  }
  details.drill summary {
    font-size: 13px;
    letter-spacing: 0.1em;
    cursor: pointer;
    list-style: none;
    display: flex;
    justify-content: space-between;
  }
  details.drill summary::-webkit-details-marker { display: none; }
  details.drill[open] summary { margin-bottom: 16px; }
  .drill-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 20px;
  }
  .drill-block h4 {
    font-size: 12px;
    letter-spacing: 0.1em;
    color: #888;
    font-weight: 500;
    margin-bottom: 8px;
    border-bottom: 1px solid #ccc;
    padding-bottom: 4px;
  }
  .drill-block .line {
    font-size: 14px;
    display: flex;
    justify-content: space-between;
    padding: 2px 0;
  }
  .drill-block .line .dk { color: #888; }
  .raw {
    font-size: 12px;
    color: #666;
    white-space: pre-wrap;
    word-break: break-all;
    line-height: 1.5;
  }

  @media (max-width: 680px) {
    body { padding: 32px 12px; }
    .glance { grid-template-columns: 1fr; }
    .drill-grid { grid-template-columns: 1fr; }
    .big { font-size: 60px; }
    .phase-elapsed { font-size: 48px; }
  }
</style>
</head>
<body>

  <div class="conn-bar">
    <div class="conn-left">
      <span class="label">CONN</span>
      <span class="conn-dot" id="conn-dot"></span>
      <span id="conn-state">—</span>
    </div>
    <div class="conn-right">
      LAST <b id="last">—</b> DELTA <b id="delta">—</b> SEQ <b id="seq">—</b>
    </div>
  </div>

  <div class="glance">
    <div class="cell">
      <div class="k">PHASE</div>
      <div class="phase-name" id="phase-name">—</div>
      <div class="phase-elapsed"><span id="phase-elapsed">—</span><span class="unit">s</span></div>
    </div>
    <div class="cell">
      <div class="k">GRIPPER</div>
      <div class="grip" id="grip">—</div>
    </div>
    <div class="cell">
      <div class="k">J1 (ANGLES[0])</div>
      <div class="big"><span id="j1">—</span><span class="unit">deg</span></div>
    </div>
    <div class="cell trial">
      <div class="k">TRIAL</div>
      <div class="row"><span class="tk">ID</span><span id="trial-id">—</span></div>
      <div class="row"><span class="tk">TASK</span><span id="trial-task">—</span></div>
    </div>
  </div>

  <details class="drill">
    <summary>
      <span>DRILL-IN — JOINTS / EE / VERDICT / RAW</span>
      <span>[ + ]</span>
    </summary>
    <div class="drill-grid">
      <div class="drill-block">
        <h4>JOINTS (deg)</h4>
        <div class="line"><span class="dk">j1</span><span id="dj1">—</span></div>
        <div class="line"><span class="dk">j2</span><span id="dj2">—</span></div>
        <div class="line"><span class="dk">j3</span><span id="dj3">—</span></div>
        <div class="line"><span class="dk">j4</span><span id="dj4">—</span></div>
        <div class="line"><span class="dk">j5</span><span id="dj5">—</span></div>
        <div class="line"><span class="dk">j6</span><span id="dj6">—</span></div>
      </div>
      <div class="drill-block">
        <h4>EE (m)</h4>
        <div class="line"><span class="dk">x</span><span id="eex">—</span></div>
        <div class="line"><span class="dk">y</span><span id="eey">—</span></div>
        <div class="line"><span class="dk">z</span><span id="eez">—</span></div>
        <h4 style="margin-top:16px;">VERDICT</h4>
        <div class="line"><span class="dk">success</span><span id="vsuccess">—</span></div>
        <div class="line"><span class="dk">reward</span><span id="vreward">—</span></div>
        <div class="line"><span class="dk">reason</span><span id="vreason">—</span></div>
      </div>
    </div>
    <div style="margin-top:16px;">
      <h4 style="font-size:12px;letter-spacing:0.1em;color:#888;font-weight:500;margin-bottom:8px;border-bottom:1px solid #ccc;padding-bottom:4px;">RAW</h4>
      <div class="raw" id="raw">—</div>
    </div>
  </details>

<script>
  const STALE_MS = 2000;
  const $ = (id) => document.getElementById(id);

  let state = {
    phase: null,
    phase_t0: null,
    last_received_ms: null,
    server_now_ms: null,
  };

  // フェーズ整形: 末尾の _a{N} を先に切り出してから stage 番号部を読む
  function fmtPhase(raw) {
    if (!raw) return "—";
    let suffix = "";
    let m = raw.match(/^(.*)_a(\d+)$/);
    if (m) { raw = m[1]; suffix = " (a" + m[2] + ")"; }
    const sm = raw.match(/^(stage\d+[a-z]*)_?(.*)$/);
    if (sm) {
      const head = sm[1].toUpperCase();
      const tail = sm[2] ? " " + sm[2].toUpperCase() : "";
      return head + tail + suffix;
    }
    return raw.toUpperCase() + suffix;
  }

  function fmtGripper(g) {
    if (g === 1) return { text: "CLOSE", cls: "grip close" };
    if (g === 0) return { text: "OPEN", cls: "grip" };
    return { text: "—", cls: "grip" };
  }

  function fmtNum(v, digits) {
    if (v === null || v === undefined || typeof v !== "number") return "—";
    return v.toFixed(digits);
  }

  function applyDatagram(dg, received_at_ms, server_now_ms) {
    state.server_now_ms = server_now_ms;
    state.last_received_ms = received_at_ms;

    if (!dg) {
      $("seq").textContent = "—";
      return;
    }

    $("seq").textContent = (dg.seq !== undefined && dg.seq !== null) ? dg.seq : "—";

    // PHASE
    state.phase = dg.phase || null;
    state.phase_t0 = dg.phase_t0 || null;
    $("phase-name").textContent = fmtPhase(dg.phase);

    // GRIPPER
    const g = fmtGripper(dg.gripper);
    const ge = $("grip");
    ge.textContent = g.text;
    ge.className = g.cls;

    // J1
    const angles = dg.angles;
    if (angles && angles.length >= 1) {
      $("j1").textContent = fmtNum(angles[0], 2);
    } else {
      $("j1").textContent = "—";
    }

    // TRIAL
    if (dg.trial) {
      $("trial-id").textContent = dg.trial.id || "—";
      $("trial-task").textContent = dg.trial.task || "—";
    } else {
      $("trial-id").textContent = "—";
      $("trial-task").textContent = "—";
    }

    // DRILL-IN joints
    for (let i = 0; i < 6; i++) {
      const el = $("dj" + (i + 1));
      if (angles && angles.length > i) el.textContent = fmtNum(angles[i], 2);
      else el.textContent = "—";
    }

    // DRILL-IN ee
    const ee = dg.ee;
    $("eex").textContent = (ee && ee.length > 0) ? fmtNum(ee[0], 4) : "—";
    $("eey").textContent = (ee && ee.length > 1) ? fmtNum(ee[1], 4) : "—";
    $("eez").textContent = (ee && ee.length > 2) ? fmtNum(ee[2], 4) : "—";

    // DRILL-IN verdict
    const v = dg.verdict;
    if (v) {
      $("vsuccess").textContent = (v.success !== undefined && v.success !== null) ? String(v.success) : "—";
      $("vreward").textContent = (v.reward !== undefined && v.reward !== null) ? String(v.reward) : "—";
      $("vreason").textContent = v.reason || "—";
    } else {
      $("vsuccess").textContent = "—";
      $("vreward").textContent = "—";
      $("vreason").textContent = "—";
    }

    // RAW
    $("raw").textContent = JSON.stringify(dg, null, 2);

    tickElapsed();
  }

  // 経過秒のローカル時計。datagram の谷間でも進める
  function tickElapsed() {
    if (state.phase_t0 === null) {
      $("phase-elapsed").textContent = "—";
      return;
    }
    const now = Date.now();
    const elapsed = (now - state.phase_t0) / 1000.0;
    $("phase-elapsed").textContent = elapsed.toFixed(1);
  }

  // stale 判定とコネクション表示
  function tickStale() {
    if (state.last_received_ms === null) return;
    const now = Date.now();
    const delta = now - state.last_received_ms;
    document.body.classList.toggle("stale", delta > STALE_MS);
    $("delta").textContent = (delta / 1000.0).toFixed(1) + "s";

    const d = new Date(state.last_received_ms);
    const hh = String(d.getHours()).padStart(2, "0");
    const mm = String(d.getMinutes()).padStart(2, "0");
    const ss = String(d.getSeconds()).padStart(2, "0");
    const ms = String(d.getMilliseconds()).padStart(3, "0");
    $("last").textContent = `${hh}:${mm}:${ss}.${ms}`;
  }

  setInterval(() => { tickElapsed(); tickStale(); }, 100);

  // WebSocket 接続
  function connect() {
    const ws = new WebSocket("ws://127.0.0.1:8000/ws");
    ws.onopen = () => {
      $("conn-state").textContent = "open";
      $("conn-dot").style.background = "var(--fg)";
    };
    ws.onclose = () => {
      $("conn-state").textContent = "closed";
      $("conn-dot").style.background = "#bbb";
      setTimeout(connect, 1000);
    };
    ws.onmessage = (ev) => {
      try {
        const msg = JSON.parse(ev.data);
        applyDatagram(msg.datagram, msg.received_at_ms, msg.server_now_ms);
      } catch (e) {}
    };
  }
  connect();
</script>
</body>
</html>

経過秒の扱いに、このUIの肝がある。tickElapsed は WebSocket の受信に依存せず、ローカルの setInterval で100ミリ秒ごとに Date.now() - phase_t0 を再計算する。なぜそうしたかは、後の実機運転で分かる。

制御ループへの接続:印字を1文字も変えない

供給源は制御ループ自身、と決めた。だが制御ループ(run_pick_place.py、本体993行)は、これまでの実機ログが全て print 出力を前提に書かれている。その印字フォーマットを変えると、過去の記録との互換が壊れる。

そこで、既存の print には一切触れず、観測 emit を脇に追加する方針を取った。状態(フェーズ・グリッパー)はモジュール辞書 _OBSERVE_STATE に持ち、各ステージで更新する。emit 本体は _do_move が移動後の現在角を取得した直後の1か所に集約する——全11ステージの移動がこの関数を経由するので、1か所で全フェーズの角度がブラウザに届く。_daemon_get_angles_filtered() の戻り値を再利用するので、daemon への問い合わせは1回も増えない。

追加分の差分は次の通り。全て追加行(>)で、削除・変更行は1つも無い。

91a92,99
> # 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
> # 観測モジュールが理由で制御が止まらないよう、import を guard する
> try:
>     from observe.emit import emit as _observe_emit  # noqa: E402
> except Exception:
>     def _observe_emit(*_args, **_kwargs):  # 観測無効でも制御は動く
>         pass
> 
118a127,150
> # ---- 観測 emit 用の状態(run() が更新、_do_move が読む)-----------------
> # 状態は run() スコープ所有(_observe_phase / _observe_gripper ヘルパ経由で更新)。
> # 内側関数(stage_lookup_and_move)には phase 更新を持たせない。
> # emit は _do_move の post_actual 取得直後の 1 サイトに集約する(最小変更)。
> _OBSERVE_STATE = {
>     "phase": None,
>     "phase_t0_ms": None,
>     "gripper": None,
>     "run_stem": None,
>     "task": "pick_and_place_with_j0_table",
> }
> 
> 
> def _observe_phase(phase_str):
>     """run() からフェーズ遷移を記録する。phase_t0 も自動更新."""
>     _OBSERVE_STATE["phase"] = phase_str
>     _OBSERVE_STATE["phase_t0_ms"] = int(time.time() * 1000)
> 
> 
> def _observe_gripper(state):
>     """run() から gripper 状態を記録する(0=open / 1=close)."""
>     _OBSERVE_STATE["gripper"] = state
> 
> 
189a222,232
>     # 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
>     # post_actual を再利用するので daemon I/O は増えない。print 出力にも影響しない
>     _observe_emit(
>         angles=post_actual,
>         phase=_OBSERVE_STATE["phase"],
>         phase_t0_ms=_OBSERVE_STATE["phase_t0_ms"],
>         gripper=_OBSERVE_STATE["gripper"],
>         trial=({"id": _OBSERVE_STATE["run_stem"],
>                 "task": _OBSERVE_STATE["task"]}
>                if _OBSERVE_STATE["run_stem"] else None),
>     )
534a578,582
>     # 観測 emit 用の状態を初期化(このプロセスで複数 run() を回す場合の前回残置を消す)
>     _OBSERVE_STATE["phase"] = None
>     _OBSERVE_STATE["phase_t0_ms"] = None
>     _OBSERVE_STATE["gripper"] = None
>     _OBSERVE_STATE["run_stem"] = run_stem
586a635
>         _observe_phase("stage1")
588a638,639
>         # stage1 で gripper open まで完了
>         _observe_gripper(0)
590a642
>         _observe_phase("stage2")
604a657
>             _observe_phase("stage3_approach_pick" + suf)
612a666
>             _observe_phase("stage4_pick" + suf)
619a674
>             _observe_phase("stage5" + suf)
624a680
>             _observe_gripper(1)
626a683
>             _observe_phase("stage6" + suf)
687a745
>             _observe_gripper(0)
721a780
>             _observe_phase("pickfail_to_home")
736a796
>         _observe_phase("stage7_approach_place")
744a805
>         _observe_phase("stage8_place")
751a813
>         _observe_phase("stage9")
755a818
>         _observe_gripper(0)
758a822
>         _observe_phase("stage9b_judge_place")
793a858
>         _observe_phase("stage10")
828a894
>             _observe_phase("abort_recover_home")

この変更は制御コードに触れるため、実機の前に外部レビュー(codex)を通した。判定は「制御非干渉として実機テストへ進んでよい」。指摘は2点の注意のみで、いずれも制御の振る舞いには影響しないもの——ひとつは emit を _do_move の1か所に集約したことで、移動を伴わないステージ(グリッパー開閉など)の phase 名が次の移動で上書きされ datagram に出ないこと。もうひとつは送信ソケットが既定でブロッキングだったこと。後者は前述の setblocking(False) で塞いだ。前者は表示の粒度の問題として、実機を見てから判断することにした。

実機運転:初日に失敗の反復が見えた

daemon を起動し、観測サーバとブラウザを立て、いつもの Pick&Place を1回流した。アームが動き、その状態がブラウザにライブで映った。

そして、初日のこの1回が、テレメトリダッシュボードを作る理由をそのまま実演することになった。pick が3回続けて失敗し、4回目で成功し、その直後の place で到達失敗して中断した。実機ログの全量を残す。

======================================================================
[RUN] run_pick_place.py  dry_run=False  log=run_0001_20260616_113906.json
======================================================================
  [Pi] fresh_mode=1
[INIT  ] fresh_mode=1
[STAGE 1] HOME 復帰 + グリッパー開
[SEND] stage1_to_home: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=1.93°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.93° (tol 2.5°)
[MOVE  ] stage1_to_home  arrived=True retried=False post_max|Δ|=1.930°
[GRIP  ] stage1_grip_open  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[380, 1666] Y=[128, 1079]
[06f] TEMPLATES_DIR overridden to: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/place/templates
[06f] ROTATION_ANGLES_DEG overridden to: [0, 15, 30, 45, 60, 75]
[06f] SCALES overridden to: [0.85, 1.0, 1.15]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1461,730) score=0.656 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.09s)
[DETECT][1/3] box Unity=(-0.217,+0.210) score=0.6558569669723511
  赤ブロック pixel=(592, 860) → Unity=(0.2109, 0.2592)
[DETECT][1/3] block Unity=(+0.211,+0.259) score=null
[06f] DETECT: center=(1460,731) score=0.656 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.12s)
[DETECT][2/3] box Unity=(-0.216,+0.210) score=0.6555615067481995
  赤ブロック pixel=(592, 861) → Unity=(0.2109, 0.2597)
[DETECT][2/3] block Unity=(+0.211,+0.260) score=null
[06f] DETECT: center=(1460,731) score=0.655 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.13s)
[DETECT][3/3] box Unity=(-0.216,+0.210) score=0.6553437113761902
  赤ブロック pixel=(592, 860) → Unity=(0.2109, 0.2592)
[DETECT][3/3] block Unity=(+0.211,+0.259) score=null
[DETECT] block_xz=[0.2108844451904297, 0.2591982116699219] block_score=None
[DETECT] box_xz=[-0.21600440979003907, 0.20998764038085938] box_score=0.6555615067481995
[LOOKUP] stage3_approach_pick  phase=approach_pick  target_xz=(+0.2109,+0.2592)  → j0=-32.518°  method=interp  x_resid=0.00000m  z_resid=0.00627m  joint_ok=True
[SEND] stage3_approach_pick: send_angles (single) [ -32.52,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=86.16°, post-send timeout 14.9s)
[SEND] arrival OK: max|Δ|=1.56° (tol 2.0°)
[MOVE  ] stage3_approach_pick  arrived=True retried=False post_max|Δ|=1.420°
[LOOKUP] stage4_pick  phase=pick  target_xz=(+0.2109,+0.2592)  → j0=-39.626°  method=interp  x_resid=0.00000m  z_resid=0.03529m  joint_ok=True
[SEND] stage4_pick: send_angles (single) [ -39.63,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.47°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.23° (tol 3.0°)
[MOVE  ] stage4_pick  arrived=True retried=False post_max|Δ|=1.230°
[GRIP  ] stage5_grip_close  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift: send_angles (single) [ -32.52,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick.jpg
[JUDGE ] pick attempt=1 adopted=no (vlm=no human=None) confidence=0.92 reason=爪の先端付近には赤いワイヤーは見えるが赤いブロックは把持されておらず、白いブロックが爪から離れた場所(右下)に置かれている
[GRIP  ] regrasp_open_a1  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a1: send_angles (single) [ +85.00,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=117.87°, post-send timeout 19.7s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] regrasp_retreat_a1  arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[384, 1661] Y=[129, 1079]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.19s)
[DETECT][1/3] box Unity=(-0.215,+0.211) score=0.6512719988822937
  赤ブロック pixel=(575, 889) → Unity=(0.2216, 0.2729)
[DETECT][1/3] block Unity=(+0.222,+0.273) score=null
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.29s)
[DETECT][2/3] box Unity=(-0.215,+0.211) score=0.6514489054679871
  赤ブロック pixel=(575, 889) → Unity=(0.2216, 0.2729)
[DETECT][2/3] block Unity=(+0.222,+0.273) score=null
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.07s)
[DETECT][3/3] box Unity=(-0.215,+0.211) score=0.6511404514312744
  赤ブロック pixel=(576, 888) → Unity=(0.2211, 0.2725)
[DETECT][3/3] block Unity=(+0.221,+0.272) score=null
[DETECT] block_xz=[0.22160145568847656, 0.2729190673828125] block_score=None
[DETECT] box_xz=[-0.21489224243164062, 0.21066572570800782] box_score=0.6512719988822937
[LOOKUP] stage3_approach_pick_a2  phase=approach_pick  target_xz=(+0.2216,+0.2729)  → j0=-34.555°  method=interp  x_resid=0.00000m  z_resid=0.01658m  joint_ok=True
[SEND] stage3_approach_pick_a2: send_angles (single) [ -34.55,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=119.54°, post-send timeout 19.9s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage3_approach_pick_a2  arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a2  phase=pick  target_xz=(+0.2216,+0.2729)  → j0=-42.600°  method=interp  x_resid=0.00000m  z_resid=0.06027m  joint_ok=True
[INFO  ] stage4_pick_a2 z_residual_m=0.0603m (情報のみ・ABORT しない。工程跨ぎや弧高さ差で構造的に大きいことがある)
[SEND] stage4_pick_a2: send_angles (single) [ -42.60,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE  ] stage4_pick_a2  arrived=True retried=False post_max|Δ|=1.410°
[GRIP  ] stage5_grip_close_a2  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a2: send_angles (single) [ -34.55,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift_a2  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick_a2 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick_a2.jpg
[JUDGE ] pick attempt=2 adopted=no (vlm=no human=None) confidence=0.9 reason=爪の先端付近に赤いものが見えるが、それはワイヤー/ケーブルであり、白い立方体ブロックがマット右側に別途置かれている。爪は何も掴んでいない状態に見える。
[GRIP  ] regrasp_open_a2  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a2: send_angles (single) [ +85.00,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=119.98°, post-send timeout 20.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] regrasp_retreat_a2  arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[383, 1661] Y=[129, 1079]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1454,730) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.14s)
[DETECT][1/3] box Unity=(-0.215,+0.210) score=0.6509156227111816
  赤ブロック pixel=(575, 889) → Unity=(0.2215, 0.2728)
[DETECT][1/3] block Unity=(+0.221,+0.273) score=null
[06f] DETECT: center=(1455,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.98s)
[DETECT][2/3] box Unity=(-0.215,+0.210) score=0.6506261825561523
  赤ブロック pixel=(588, 822) → Unity=(0.2145, 0.2405)
[DETECT][2/3] block Unity=(+0.214,+0.240) score=null
[06f] DETECT: center=(1455,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.95s)
[DETECT][3/3] box Unity=(-0.215,+0.210) score=0.6507194638252258
  赤ブロック pixel=(588, 822) → Unity=(0.2145, 0.2405)
[DETECT][3/3] block Unity=(+0.214,+0.240) score=null
[DETECT] block_xz=[0.21448802185058594, 0.2404526824951172] block_score=None
[DETECT] box_xz=[-0.21527491760253906, 0.21001191711425782] box_score=0.6507194638252258
[LOOKUP] stage3_approach_pick_a3  phase=approach_pick  target_xz=(+0.2145,+0.2405)  → j0=-33.203°  method=interp  x_resid=0.00000m  z_resid=0.02195m  joint_ok=True
[SEND] stage3_approach_pick_a3: send_angles (single) [ -33.20,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=117.83°, post-send timeout 19.7s)
[SEND] arrival OK: max|Δ|=1.48° (tol 2.0°)
[MOVE  ] stage3_approach_pick_a3  arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a3  phase=pick  target_xz=(+0.2145,+0.2405)  → j0=-40.492°  method=interp  x_resid=0.00000m  z_resid=0.01972m  joint_ok=True
[SEND] stage4_pick_a3: send_angles (single) [ -40.49,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE  ] stage4_pick_a3  arrived=True retried=False post_max|Δ|=1.410°
[GRIP  ] stage5_grip_close_a3  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a3: send_angles (single) [ -33.20,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift_a3  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick_a3 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick_a3.jpg
[JUDGE ] pick attempt=3 adopted=yes (vlm=yes human=None) confidence=0.85 reason=ロボットアームの爪(グリッパー)の先端部分に赤い四角いブロックが挟まれており、爪が閉じた状態でブロックを保持しているように見える
[LOOKUP] stage7_approach_place  phase=approach_place  target_xz=(-0.2160,+0.2100)  → j0=+43.213°  method=interp  x_resid=0.00000m  z_resid=0.00303m  joint_ok=True
[SEND] stage7_approach_place: send_angles (single) [ +43.21,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s  (pre max|Δ|=76.34°, post-send timeout 13.5s)
[SEND] [TIMEOUT] 13.5s 内に未到達: max|Δ|=2.46° actual=[ +43.50,  +59.85,  +17.66,   +0.17,  -89.91,  -29.61]
[SEND] リトライしません: pre→last max|Δ| 76.63° >= 1.0° (部分移動/ストール疑い)
[MOVE  ] stage7_approach_place  arrived=False retried=False post_max|Δ|=2.460°
[ABORT ] stage=7  reason=到達失敗
[SEND] abort_recover_home: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=89.91°, post-send timeout 15.5s)
[SEND] arrival OK: max|Δ|=2.46° (tol 2.5°)
[MOVE  ] abort_recover_home  arrived=True retried=False post_max|Δ|=2.460°
[RECOVER] ABORT 後 HOME 復帰: OK
  [Pi] fresh_mode=0
[LOG   ] /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906.json

pick の3回の判定理由を並べると、失敗の質が見える。1回目「赤いワイヤーは見えるが赤いブロックは把持されておらず」、2回目「赤いものが見えるが、それはワイヤー/ケーブルであり」、3回目で初めて「赤い四角いブロックが挟まれており」。爪先のワイヤーをブロックと見間違える、あるいは実際に掴めていない——同じ原因で2回続けて落ち、3回目で掴めた。その後の place で到達に失敗(13.5秒内に未到達・部分移動かストール疑い)し、HOME へ復帰して中断した。

ブラウザは何を見せたか

この運転の間、ブラウザには PHASE がステージ名で表示され、その下の経過秒が動き、GRIPPER が OPEN と CLOSE を切り替え、J1 が実際の姿勢を追った。掴み直しの2回目では PHASE が「STAGE4 PICK (a2)」と接尾辞付きで表示され、J1 はログの送信値 -42.60 と整合する値を示した。

ここで、経過秒をローカルの setInterval で進める設計が効いた。移動と移動の間には数秒の空白がある——ある瞬間のスクリーンショットでは、最後の datagram から11.0秒が経過していた。経過秒を datagram の受信に同期させていたら、この11秒間は画面が固まって見える。だが実際にはローカル時計が独立して刻み続け、データ供給が止まっている間も経過秒は増え続けた。データが来ない谷間そのものが、「このフェーズに何秒留まっているか」として見える。これが、ターミナルが原理的に見せられなかった「詰まり」の可視化になる。

注入テストでこの挙動を単独で確認している。経過秒の起点を5秒前に設定した datagram を1回だけ送り、以後の送信を止める。それでも画面の経過秒は5.0から6.0、7.0と増え続けた。供給が1発きりでも、ローカル時計が進む。

初日に見えた、次にやること

このダッシュボードはまだ完成ではない。むしろ、初日の運転が「足りないもの」を具体的に示した。

VLM は pick の各試行で判定理由を出していた。「ワイヤーをブロックと見間違えている」という、改善に直結する情報だ。だがそれは今、ターミナルにしか無い。スキーマには verdict の席を予約してあるが、まだ空のままだ。次はここを埋める——成否判定をダッシュボードに乗せ、ドリルインの「—」を、失敗の理由が語る欄に変える。

そしてもうひとつ。この運転でいちばん意味があった情報は「同じ理由で3回続けて落ちた」という、試行をまたいだパターンだった。だが現在のグランスは、1試行の中の「今」しか見せない。失敗の反復を画面で見せるには、試行の履歴を持つ必要がある。単一の datagram を超えて、時系列を持つダッシュボードへ。

供給(emit)・中継(server)・表示(html)を分けたので、これらの拡張は表示側を育てるだけで足りる。制御ループには、もう触らない。観測は制御の一部ではない。制御を変えずに、制御の外側へ状態を逃がす層だ。

ターミナルからブラウザへ移したのは、見た目ではない。失敗の見え方そのものだ。次のログでは、見えた失敗を画面の上で読めるようにする。

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