#!/usr/bin/env python3 """test_recorder_rpc.py — 記録の角度の抜け (依頼 46 B) を偽の daemon で確かめる (アームなし. 2026-10-31). python3 pi05/test_recorder_rpc.py # 全部 python3 pi05/test_recorder_rpc.py -v 偽の daemon は本物 (mecharm_daemon.py) と同じく 1 本のつながりを 1 件ずつ処理する. send_angles を受けると, 腕を 速度 20 の速さ (いちばん大きく動く関節 30°/秒) で目標へ動かし始め, 送ったあとの読み直しの分 (READBACK_SEC) 待ってから返事をする. get_angles は 35ms で今の角度を返す. 本体の側は run_policy_client.py の中継点と同じ順 (read_angles → _send_and_wait_arrival → read_angles) で送る. 確かめること: - 記録をつけても daemon に届く指令 (cmd・目標・速度・wait) の並びは変わらない - send_angles 行 (始めた時刻・目標・やりとりの長さ) と, 本体が読んだ角度の actual 行 (note=main) が残る - 動いている間の実測角の間隔 (読み直し 1.6 秒の daemon / 読み直しのない daemon の比較を表で出す) KIOXIA にもアームにも触らない (一時フォルダと 127.0.0.1 だけ). """ import csv import io import json import socket import sys import tempfile import threading import time import unittest from pathlib import Path from unittest import mock import numpy as np _HERE = Path(__file__).resolve().parent sys.path.insert(0, str(_HERE)) import run_policy_client as rpc # noqa: E402 import policy_recorder as pr # noqa: E402 import visual_servo_pick as vsp # noqa: E402 SPEED_DPS = 30.0 GET_SEC = 0.035 START = [0.0, 10.0, 10.0, 0.0, -10.0, -30.0] VIAS = [[2.0, 16.0, 14.0, -0.5, -18.0, -29.5], [4.0, 22.0, 18.0, -1.0, -26.0, -29.0], [6.0, 28.0, 22.0, -1.5, -34.0, -28.5]] class FakeDaemon: """1 本のつながりを 1 件ずつ処理する偽の daemon (腕の動きは時刻で決める).""" def __init__(self, readback_sec): self.readback_sec = readback_sec self.cmds = [] self.pose0 = np.array(START, dtype=float) self.goal = self.pose0.copy() self.t_move = 0.0 self.srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.srv.bind(("127.0.0.1", 0)) self.srv.listen(1) self.port = self.srv.getsockname()[1] self.stop = False self.th = threading.Thread(target=self._serve, daemon=True) self.th.start() def pose(self, now=None): now = time.time() if now is None else now d = float(np.max(np.abs(self.goal - self.pose0))) if d <= 0: return self.goal.copy() f = min(1.0, (now - self.t_move) * SPEED_DPS / d) return self.pose0 + f * (self.goal - self.pose0) def _handle(self, c): cmd = c.get("cmd") if cmd == "ping": return {"ok": True} self.cmds.append({k: c[k] for k in sorted(c)}) if cmd == "get_angles": time.sleep(GET_SEC) return {"ok": True, "angles": [round(float(v), 2) for v in self.pose()]} if cmd == "send_angles": self.pose0 = self.pose() self.goal = np.array(c["angles"], dtype=float) self.t_move = time.time() time.sleep(float(c.get("wait", 0)) + self.readback_sec) # 送ったあとの読み直しの分 return {"ok": True, "result": 1, "elapsed_ms": 10.0} return {"ok": False, "error": f"unknown cmd: {cmd}"} def _serve(self): while not self.stop: try: cs, _ = self.srv.accept() except OSError: return buf = b"" with cs: while not self.stop: data = cs.recv(4096) if not data: break buf += data while b"\n" in buf: line, buf = buf.split(b"\n", 1) resp = self._handle(json.loads(line)) cs.sendall((json.dumps(resp) + "\n").encode()) def close(self): self.stop = True try: self.srv.close() except Exception: pass class FakeCap: def read(self): time.sleep(1 / 30) return True, np.full((72, 128, 3), 100, np.uint8) def run_vias(readback_sec, record): """偽の daemon に中継点を 3 つ送る. 戻り値: (daemon に届いた指令, states.csv の行, 動いた窓).""" d = FakeDaemon(readback_sec) vsp.close_daemon_socket() pr.install_daemon_lock() windows = [] with tempfile.TemporaryDirectory() as td, \ mock.patch.object(vsp, "DAEMON_HOST", "127.0.0.1"), mock.patch.object(vsp, "DAEMON_PORT", d.port), \ mock.patch("sys.stdout", new=io.StringIO()): rec = None if record: rec = pr.PolicyRecorder(Path(td), FakeCap(), rpc.read_angles_once, lambda f: (f, f), angles_ok=rpc.wr._is_valid_angles) rec.start() time.sleep(0.5) for i, v in enumerate(VIAS): qa = rpc.read_angles() t_send = time.time() ok, _ = rpc._send_and_wait_arrival(list(v), f"via{i}", 3.0, send_wait_sec=rpc.SEND_WAIT) assert ok dist = float(np.max(np.abs(np.asarray(v) - qa))) windows.append((t_send, t_send + dist / SPEED_DPS)) rpc.read_angles() time.sleep(0.5) rows = [] if rec is not None: rec.stop() with (Path(td) / "recording" / "states.csv").open(newline="") as f: rows = list(csv.DictReader(f)) vsp.close_daemon_socket() d.close() return d.cmds, rows, windows def motion_intervals(rows, windows, include_main): """動いている窓にかかる, 続けて残った実測角の間隔 (秒) の最大と, 窓の中に残った実測角の数. 窓の始まりは send_angles 行の時刻 (daemon へ送り始めた時刻) にそろえる.""" sends = [float(r["ts"]) for r in rows if r["event_type"] == "send_angles"] if len(sends) == len(windows): windows = [(s, s + (b - a)) for s, (a, b) in zip(sends, windows)] ts = sorted(float(r["ts"]) for r in rows if r["event_type"] == "actual" and (include_main or r["note"] != "main")) ts = np.array(ts) worst, inside = 0.0, 0 for a, b in windows: inside += int(((ts > a) & (ts < b)).sum()) k0 = max(0, int(np.searchsorted(ts, a)) - 1) k1 = min(len(ts) - 1, int(np.searchsorted(ts, b))) if k1 > k0: worst = max(worst, float(np.max(np.diff(ts[k0:k1 + 1])))) return worst, inside def _strip_gets(cmds): return [c for c in cmds if c["cmd"] != "get_angles"] class TestRecorderRpc(unittest.TestCase): @classmethod def setUpClass(cls): cls.res = {rb: run_vias(rb, True) for rb in (1.6, 0.0)} cls.plain = run_vias(1.6, False) def test_commands_unchanged(self): """記録をつけても daemon に届く送信の並び (目標・速度・wait) は記録なしと同じ.""" with_rec = _strip_gets(self.res[1.6][0]) without = _strip_gets(self.plain[0]) self.assertEqual(with_rec, without) self.assertEqual(len(with_rec), len(VIAS)) for c, v in zip(with_rec, VIAS): self.assertEqual(c["angles"], v) self.assertEqual(c["speed"], rpc.SPEED) self.assertEqual(c["wait"], rpc.SEND_WAIT) def test_send_rows_and_main_reads(self): cmds, rows, _ = self.res[1.6] sends = [r for r in rows if r["event_type"] == "send_angles"] self.assertEqual(len(sends), len(VIAS)) for r, v in zip(sends, VIAS): self.assertEqual([float(r[f"j{k}"]) for k in range(6)], v) self.assertEqual(r["ts"], r["ts_send"]) rpc_sec = float(r["note"].split("rpc_sec=")[1].split()[0]) self.assertGreater(rpc_sec, 1.5) # 読み直しの間, つながりがふさがる main = [r for r in rows if r["event_type"] == "actual" and r["note"] == "main"] self.assertGreater(len(main), 0) # 記録スレッドの分を二重に残さない: main 行 + 記録スレッドの行 = daemon に届いた get_angles の数 own = [r for r in rows if r["event_type"] == "actual" and r["note"] != "main"] self.assertEqual(len(main) + len(own), sum(1 for c in cmds if c["cmd"] == "get_angles")) def test_report_intervals(self): """動いている間の実測角の間隔を出す (表は標準エラーへ). 読み直し 1.6 秒では抜けが残ることも確かめる.""" out = [] for rb in (1.6, 0.0): _, rows, win = self.res[rb] for inc in (False, True): w, n = motion_intervals(rows, win, inc) out.append((rb, inc, w, n)) lines = ["", "daemon の送ったあとの読み直し | 本体の読みも残す | 動いている間の最大の間隔 | 動いている間に残った数"] for rb, inc, w, n in out: lines.append(f" {rb:.1f} 秒 | {'する' if inc else 'しない (前)'} | {w:.2f} 秒 | {n}") print("\n".join(lines), file=sys.stderr) w16 = [w for rb, inc, w, n in out if rb == 1.6] self.assertTrue(all(w > 1.0 for w in w16)) # 読み直しの間はだれも読めない w0 = [w for rb, inc, w, n in out if rb == 0.0 and inc] self.assertLess(w0[0], 0.3) # 読み直しがなければ 10Hz 近くで残る class TestObserverHook(unittest.TestCase): """install_daemon_lock の観察口: 返事・例外をそのまま返し, 入れ子の ping は渡さず, 観察の失敗で止まらない.""" def _install(self, fake): saved = vsp._daemon_request self.addCleanup(setattr, vsp, "_daemon_request", saved) with mock.patch.object(vsp, "_daemon_request", fake): pr.install_daemon_lock() wrapped = vsp._daemon_request return wrapped def test_passthrough_nested_and_errors(self): seen = [] def fake(cmd_obj, timeout_override=None): if cmd_obj["cmd"] == "get_angles": vsp._daemon_request({"cmd": "ping"}) # 再接続の中の ping (入れ子) return {"ok": True, "angles": [1, 2, 3, 4, 5, 6]} if cmd_obj["cmd"] == "boom": raise RuntimeError("daemon timeout") return {"ok": True} wrapped = self._install(fake) with mock.patch.object(vsp, "_daemon_request", wrapped): def obs(c, r, t0, t1, e): seen.append((c["cmd"], r, e)) def bad(*a): raise ValueError("observer bug") pr.add_rpc_observer(bad) pr.add_rpc_observer(obs) self.addCleanup(pr.remove_rpc_observer, bad) self.addCleanup(pr.remove_rpc_observer, obs) r = vsp._daemon_request({"cmd": "get_angles"}, timeout_override=1.0) self.assertEqual(r["angles"], [1, 2, 3, 4, 5, 6]) with self.assertRaises(RuntimeError) as cm: vsp._daemon_request({"cmd": "boom"}) self.assertEqual(str(cm.exception), "daemon timeout") self.assertEqual([x[0] for x in seen], ["get_angles", "boom"]) # 入れ子の ping は渡さない self.assertIsInstance(seen[1][2], RuntimeError) if __name__ == "__main__": unittest.main()