Добавлено управление роботом

This commit is contained in:
Даниил Грабарь
2026-09-14 14:36:46 +10:00
parent ae30c02ddd
commit f7dff2e484
8 changed files with 1589 additions and 14 deletions
+4 -1
View File
@@ -5,4 +5,7 @@ outputs/
.cache/ .cache/
.codex/ .codex/
.agents/ .agents/
iiwa_description iiwa_description/
docs/
AGENTS.md
+45 -4
View File
@@ -17,7 +17,7 @@ uv run python run_scene.py --config configs/scene.yaml
```bash ```bash
uv sync --extra cpu --locked uv sync --extra cpu --locked
uv run --extra cpu python run_scene.py --headless --steps 4 --save-frames uv run --extra cpu python run_scene.py --backend cpu --headless --steps 4 --save-frames
uv run --extra cpu pytest uv run --extra cpu pytest
``` ```
@@ -35,6 +35,46 @@ uv run pytest
IIWA_GENESIS_SMOKE=1 uv run pytest tests/test_genesis_smoke.py IIWA_GENESIS_SMOKE=1 uv run pytest tests/test_genesis_smoke.py
``` ```
Для управления во время viewer-запуска добавьте `--interactive`, а для
отдельного окна управления — `--control-window` (их можно использовать вместе):
```bash
uv run python run_scene.py --viewer --control-window
```
Окно содержит выбор режима, семь полей суставов A1..A7, шесть Cartesian-полей
`x y z roll pitch yaw`, ползунок скорости 0–100%, а также Send, Stop и Close.
Скорость ограничивает каждый сустав по его velocity limit из выбранного URDF;
100% использует полный limit, а 0% и кнопка Stop отменяют движение и удерживают
текущую целевую позу. Ошибка запуска окна не останавливает сцену.
При `--interactive` в терминале выберите режим и отправляйте команды; terminal
команды используют скорость 100%, а `set` использует выбранный режим:
```text
mode joints
set 0 0.2 0 1.0 0 0 0
mode cartesian
set 0.35 0 0.55 0 1.57 0
```
В режиме joints значения — A1..A7 в радианах; в режиме cartesian —
`x y z roll pitch yaw`. После выбора режима можно вводить и только числа, без `set`.
Cartesian-поза задаётся для TCP в системе координат базы робота (метры и
радианы), а не в мировой системе. Также доступны однострочные формы
`joints ...`, `cartesian ...`, `help` и `quit`. Для Cartesian-команд Genesis
решает IK и плавно ведёт суставы к целевой позе через position controller.
Для сцены без захвата используется `link7`, с захватом — `tcp`.
Команды вне суставных limits и решения IK с ошибкой положения больше 0,5 мм
или ориентации больше 0,005 рад отклоняются; предыдущее движение продолжается.
Stop удерживает последнюю отправленную контроллеру суставную цель.
За один шаг обрабатывается до 16 сообщений из каждой очереди ввода, чтобы
поток команд не блокировал физику. В прочитанной порции `quit` имеет приоритет
и завершает цикл до обработки GUI и следующего шага. Stop отменяет ожидающие
движения терминала и предшествующие ему GUI-команды в этой порции; GUI-команды
после Stop выполняются не раньше следующего шага.
Headless по умолчанию выполняет 120 шагов, viewer работает до закрытия окна, Headless по умолчанию выполняет 120 шагов, viewer работает до закрытия окна,
Ctrl+C или Q/Esc в окне кадров. `output.preview` включает окна RGB и глубины. Ctrl+C или Q/Esc в окне кадров. `output.preview` включает окна RGB и глубины.
Превью работает в отдельном процессе: оконные циклы OpenCV и Genesis не мешают Превью работает в отдельном процессе: оконные циклы OpenCV и Genesis не мешают
@@ -43,7 +83,8 @@ Ctrl+C или Q/Esc в окне кадров. `output.preview` включает
30 FPS — частота по времени симуляции; реальная скорость зависит от оборудования. 30 FPS — частота по времени симуляции; реальная скорость зависит от оборудования.
Headless всё равно требует рабочий OpenGL/EGL для RGB. Используется EGL, если Headless всё равно требует рабочий OpenGL/EGL для RGB. Используется EGL, если
`PYOPENGL_PLATFORM` не задан пользователем. GPU требует совместимого драйвера; `PYOPENGL_PLATFORM` не задан пользователем. GPU требует совместимого драйвера;
`backend: cpu` выбран для переносимого первого запуска. Системный драйвер и в текущем `configs/scene.yaml` выбран `backend: gpu`. Для запуска на CPU
передайте `--backend cpu` или выберите `scene.backend: cpu`. Системный драйвер и
библиотеки OpenGL предоставляются ОС, их uv не устанавливает. библиотеки OpenGL предоставляются ОС, их uv не устанавливает.
Обычные тесты проверяют URDF, конфигурацию и математику; opt-in smoke-тесты Обычные тесты проверяют URDF, конфигурацию и математику; opt-in smoke-тесты
реально строят оба мира и сохраняют RGB-D на CPU/EGL. Первичная компиляция реально строят оба мира и сохраняют RGB-D на CPU/EGL. Первичная компиляция
@@ -66,8 +107,8 @@ Python overlays, а pytest не загружает внешние плагины
сенсора дополнительно смещена на `[0, 0.014, 0.027]` м: передняя плоскость сенсора дополнительно смещена на `[0, 0.014, 0.027]` м: передняя плоскость
находится за корпусом толщиной 26 мм. Это геометрическое приближение, находится за корпусом толщиной 26 мм. Это геометрическое приближение,
исключающее измерение собственного корпуса, а не заводская калибровка. исключающее измерение собственного корпуса, а не заводская калибровка.
Нулевая гравитация удерживает выбранную позу без контроллера: v1 предназначена Сцена использует нулевую гравитацию и суставной position controller для
для осмотра сцены и получения изображений, а не динамического управления. удержания позы и выполнения команд с ограниченной скоростью.
При нулевых углах суставов фланец и камера инструмента направлены вверх. При нулевых углах суставов фланец и камера инструмента направлены вверх.
Для осмотра стола этой камерой задайте соответствующую рабочую позу в `robot.joints`. Для осмотра стола этой камерой задайте соответствующую рабочую позу в `robot.joints`.
+1 -1
View File
@@ -1,7 +1,7 @@
scene: scene:
# mode: without_gripper # with_gripper automatically adds the tool camera # mode: without_gripper # with_gripper automatically adds the tool camera
mode: with_gripper # without_gripper automatically adds the tool camera mode: with_gripper # without_gripper automatically adds the tool camera
backend: cpu # gpu on a machine with a supported GPU backend: gpu # gpu on a machine with a supported GPU
viewer: true viewer: true
dt: 0.008333333333333333 # 120 Hz simulation clock dt: 0.008333333333333333 # 120 Hz simulation clock
seed: 42 seed: 42
+120 -5
View File
@@ -2,10 +2,15 @@ import argparse
from datetime import datetime, timezone from datetime import datetime, timezone
import os import os
from pathlib import Path from pathlib import Path
from queue import Empty
import time import time
from yaml import YAMLError from yaml import YAMLError
from .config import ROOT, load_config from .config import ROOT, load_config
from .control import ModeCommand, QuitCommand, TerminalInput, help_text, parse_command
MAX_INPUTS_PER_TICK = 16
def main(): def main():
@@ -18,6 +23,10 @@ def main():
parser.add_argument("--save-frames", action="store_true") parser.add_argument("--save-frames", action="store_true")
parser.add_argument("--steps", type=int, help="finite simulation steps (headless default: 120)") parser.add_argument("--steps", type=int, help="finite simulation steps (headless default: 120)")
parser.add_argument("--backend", choices=("cpu", "gpu")) parser.add_argument("--backend", choices=("cpu", "gpu"))
parser.add_argument("--interactive", action="store_true",
help="accept joint/TCP commands from the terminal while running")
parser.add_argument("--control-window", action="store_true",
help="open a separate GUI window for joint/TCP commands")
args = parser.parse_args() args = parser.parse_args()
try: try:
cfg = load_config(args.config) cfg = load_config(args.config)
@@ -35,11 +44,33 @@ def main():
from .scene import build_scene from .scene import build_scene
gs.init(backend=getattr(gs, args.backend or cfg["scene"]["backend"]), seed=cfg["scene"]["seed"], logging_level="warning") gs.init(backend=getattr(gs, args.backend or cfg["scene"]["backend"]), seed=cfg["scene"]["seed"], logging_level="warning")
from .preview import Preview preview = None
preview = Preview() if viewer and cfg["output"]["preview"] else None control_window = None
try: try:
from .preview import Preview
preview = Preview() if viewer and cfg["output"]["preview"] else None
print("Building scene; first launch compiles Genesis kernels...", flush=True) print("Building scene; first launch compiles Genesis kernels...", flush=True)
scene, robot, cameras = build_scene(gs, cfg, viewer) scene, robot, cameras = build_scene(gs, cfg, viewer)
dof_indices = [robot.get_joint(f"joint{i}").dofs_idx_local[0] for i in range(1, 8)]
from .scene import SceneMotionController
controller = SceneMotionController(robot, dof_indices, cfg)
terminal = TerminalInput() if args.interactive else None
if terminal:
terminal.start()
print(help_text(), flush=True)
control_window_error = False
gui_commands_enabled = False
gui_diagnostics_enabled = False
if args.control_window:
try:
from .control_gui import ControlGui
control_window = ControlGui()
control_window.start()
gui_commands_enabled = True
gui_diagnostics_enabled = True
except (ImportError, OSError, RuntimeError) as exc:
control_window_error = True
print(f"Control window error: {exc}", flush=True)
directory = cfg["output"]["directory"] / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ") directory = cfg["output"]["directory"] / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ")
print(f"Mode: {cfg['scene']['mode']}; cameras: {', '.join(c.name for c in cameras)}; Genesis {gs.__version__}; backend {gs.backend}") print(f"Mode: {cfg['scene']['mode']}; cameras: {', '.join(c.name for c in cameras)}; Genesis {gs.__version__}; backend {gs.backend}")
if save: if save:
@@ -49,9 +80,87 @@ def main():
dt = cfg["scene"]["dt"] dt = cfg["scene"]["dt"]
start = time.monotonic() start = time.monotonic()
step = 0 step = 0
pending_gui = []
while steps is None or step < steps: while steps is None or step < steps:
terminal_lines = []
quit_requested = False
if terminal:
for _ in range(MAX_INPUTS_PER_TICK):
line = terminal.poll()
if line is None:
break
terminal_lines.append(line)
# Recognize quit before executing any pending movement/IK.
try:
if isinstance(parse_command(line, controller.mode), QuitCommand):
quit_requested = True
break
except (TypeError, ValueError):
pass # Parse/report in order, after preceding mode changes.
if quit_requested:
break
gui_commands = pending_gui
pending_gui = []
if control_window:
for _ in range(MAX_INPUTS_PER_TICK - len(gui_commands)):
if not gui_commands_enabled:
break
try:
gui_commands.append(control_window.commands.get_nowait())
except Empty:
break
except (AttributeError, EOFError, OSError, RuntimeError, ValueError) as exc:
gui_commands_enabled = False
print(f"Control window command queue error: {exc}", flush=True)
break
stop_index = next((index for index in range(len(gui_commands) - 1, -1, -1)
if isinstance(gui_commands[index], dict)
and gui_commands[index].get("type") == "stop"), None)
if stop_index is not None:
# Stop wins this tick; later GUI commands can resume next tick.
pending_gui = gui_commands[stop_index + 1:]
controller.apply_gui(gui_commands[stop_index])
gui_commands = []
for line in terminal_lines:
try:
command = parse_command(line, controller.mode)
if command is None:
continue
if getattr(command, "mode", None) == "help":
print(help_text(), flush=True)
elif isinstance(command, ModeCommand) or stop_index is None:
controller.apply(command)
if isinstance(command, ModeCommand):
print(f"Control mode: {command.mode}", flush=True)
except (TypeError, ValueError, RuntimeError) as exc:
print(f"Command error: {exc}", flush=True)
for command in gui_commands:
try:
controller.apply_gui(command)
except (AttributeError, KeyError, RuntimeError, TypeError, ValueError) as exc:
print(f"Command error (control window): {exc}", flush=True)
if control_window:
for _ in range(MAX_INPUTS_PER_TICK):
if not gui_diagnostics_enabled:
break
try:
diagnostic = control_window.diagnostics.get_nowait()
except Empty:
break
except (AttributeError, EOFError, OSError, RuntimeError, ValueError) as exc:
gui_diagnostics_enabled = False
print(f"Control window diagnostic queue error: {exc}", flush=True)
break
if not control_window_error:
control_window_error = True
message = diagnostic.get("message", "startup failed") if isinstance(diagnostic, dict) else diagnostic
print(f"Control window error: {message}", flush=True)
if control_window.status == "failed" and not control_window_error:
control_window_error = True
print("Control window error: startup failed", flush=True)
if viewer and not scene.viewer.is_alive(): if viewer and not scene.viewer.is_alive():
break break
controller.step()
scene.step() scene.step()
step += 1 step += 1
timestamp = step * dt timestamp = step * dt
@@ -73,9 +182,15 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
if preview: try:
preview.close() for name, resource in (("Control window", control_window), ("Preview", preview)):
gs.destroy() if resource is not None:
try:
resource.close()
except Exception as exc:
print(f"{name} close error: {exc}", flush=True)
finally:
gs.destroy()
if __name__ == "__main__": if __name__ == "__main__":
+286
View File
@@ -0,0 +1,286 @@
"""Interactive terminal control for the seven-axis iiwa."""
from dataclasses import dataclass
import numbers
import shlex
import threading
from queue import Empty, Queue
import sys
import math
import numpy as np
from scipy.spatial.transform import Rotation
@dataclass(frozen=True)
class ModeCommand:
mode: str
@dataclass(frozen=True)
class JointCommand:
angles: tuple[float, ...]
@dataclass(frozen=True)
class CartesianCommand:
position: tuple[float, float, float]
rpy: tuple[float, float, float]
@dataclass(frozen=True)
class QuitCommand:
pass
class GuiCommandQueue:
"""Adapt a multiprocessing queue to plain GUI command dictionaries."""
def __init__(self, queue=None):
if queue is None:
import multiprocessing as mp
queue = mp.get_context("spawn").Queue()
self.queue = queue
def send_joint(self, angles, speed_percent):
self._send_values("joints", angles, speed_percent, 7)
def send_cartesian(self, values, speed_percent):
self._send_values("cartesian", values, speed_percent, 6)
def _send_values(self, command_type, values, speed_percent, count):
if len(values) != count:
raise ValueError(f"{command_type} expects {count} values")
try:
values = [float(value) for value in values]
speed_percent = float(speed_percent)
except (TypeError, ValueError) as exc:
raise ValueError("GUI values must be numeric") from exc
if not all(math.isfinite(value) for value in values + [speed_percent]):
raise ValueError("GUI values must be finite")
if not 0 <= speed_percent <= 100:
raise ValueError("speed_percent must be between 0 and 100")
self.queue.put({"type": "stop"} if speed_percent == 0 else {
"type": command_type, "values": values,
"speed_percent": speed_percent,
})
def send_stop(self):
self.queue.put({"type": "stop"})
def send_close(self):
self.queue.put({"type": "close"})
def poll(self):
try:
return self.queue.get(timeout=0.05)
except (Empty, OSError, ValueError):
return None
def close(self):
close = getattr(self.queue, "close", None)
if close is not None:
close()
def _scalar_float(value, name):
if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
raise ValueError(f"{name} must be a real numeric scalar")
try:
value = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"{name} must be numeric") from exc
if not np.isfinite(value):
raise ValueError(f"{name} must be finite")
return value
class VelocityLimitedMotion:
"""Generate joint targets that respect scaled URDF velocity limits."""
def __init__(self, limits):
self.limits = np.asarray(limits, dtype=float)
if self.limits.ndim != 1 or not self.limits.size:
raise ValueError("limits must be a non-empty sequence")
if not np.isfinite(self.limits).all() or (self.limits <= 0).any():
raise ValueError("limits must be positive and finite")
self.target = None
self._speed_limits = None
self._stopped = False
self.complete = True
def set_target(self, q, speed_percent):
speed_percent = _scalar_float(speed_percent, "speed_percent")
if not 0 < speed_percent <= 100:
raise ValueError("speed_percent must be between 0 and 100")
target = np.asarray(q, dtype=float)
if target.shape != self.limits.shape or not np.isfinite(target).all():
raise ValueError("target must match limits and contain finite values")
self.target = target.copy()
self._speed_limits = self.limits * (speed_percent / 100)
self._stopped = False
self.complete = False
def step(self, current_q, dt):
if self.target is None:
raise RuntimeError("set_target must be called before step")
dt = _scalar_float(dt, "dt")
if dt < 0:
raise ValueError("dt must be non-negative")
current = np.asarray(current_q, dtype=float)
if current.shape != self.limits.shape or not np.isfinite(current).all():
raise ValueError("current_q must match limits and contain finite values")
if self._stopped:
return self.target.copy(), True
delta = self.target - current
progress = np.minimum(np.abs(delta), self._speed_limits * dt)
position = current + np.sign(delta) * progress
self.complete = bool(np.array_equal(position, self.target))
return position, self.complete
def stop(self, current_q):
current = np.asarray(current_q, dtype=float)
if current.shape != self.limits.shape or not np.isfinite(current).all():
raise ValueError("current_q must match limits and contain finite values")
self.target = current.copy()
self._speed_limits = np.zeros_like(self.limits)
self._stopped = True
self.complete = True
def _numbers(values, count, name):
if len(values) != count:
raise ValueError(f"{name} expects {count} values")
try:
result = tuple(float(value) for value in values)
except ValueError as exc:
raise ValueError(f"{name} values must be numbers") from exc
if not np.isfinite(result).all():
raise ValueError(f"{name} values must be finite")
return result
def parse_command(line, mode=None):
"""Parse one terminal command; Cartesian values are TCP pose in base frame."""
try:
words = shlex.split(line)
except ValueError as exc:
raise ValueError(f"invalid command quoting: {exc}") from exc
if not words:
return None
command = words[0].lower()
arguments = words[1:]
if command == "mode":
if len(words) != 2 or words[1].lower() not in ("joints", "cartesian"):
raise ValueError("mode must be joints or cartesian")
return ModeCommand(words[1].lower())
if command in ("quit", "exit"):
if len(words) != 1:
raise ValueError("quit takes no values")
return QuitCommand()
if command == "help":
return ModeCommand("help")
if command == "set":
if mode not in ("joints", "cartesian"):
raise ValueError("select a mode first: mode joints or mode cartesian")
command = mode
if command == "joints":
return JointCommand(_numbers(arguments, 7, "joints"))
if command in ("cartesian", "tcp"):
values = _numbers(arguments, 6, "cartesian")
return CartesianCommand(values[:3], values[3:])
if mode == "joints":
return JointCommand(_numbers(words, 7, "joints"))
if mode == "cartesian":
values = _numbers(words, 6, "cartesian")
return CartesianCommand(values[:3], values[3:])
raise ValueError("unknown command; type help")
def base_pose_to_world(base_position, base_euler, target_position, target_rpy):
"""Build a world TCP pose from a target pose expressed in robot-base axes."""
base = np.eye(4)
base[:3, :3] = Rotation.from_euler("xyz", np.deg2rad(base_euler)).as_matrix()
base[:3, 3] = base_position
target = np.eye(4)
target[:3, :3] = Rotation.from_euler("xyz", target_rpy).as_matrix()
target[:3, 3] = target_position
return base @ target
def configure_robot_control(robot, dofs_idx_local):
"""Configure stable, bounded PD position control for the seven arm joints."""
robot.set_dofs_kp(np.array([450., 450., 350., 350., 200., 200., 200.]),
dofs_idx_local=dofs_idx_local)
robot.set_dofs_kv(np.array([45., 45., 35., 35., 20., 20., 20.]),
dofs_idx_local=dofs_idx_local)
robot.set_dofs_force_range(
lower=np.full(7, -200.), upper=np.full(7, 200.),
dofs_idx_local=dofs_idx_local)
class TerminalInput:
"""Read stdin in a daemon thread so simulation and viewer remain responsive."""
def __init__(self, stream=sys.stdin):
self.commands = Queue()
self.stream = stream
self.thread = threading.Thread(target=self._read, daemon=True)
def start(self):
self.thread.start()
def _read(self):
for line in self.stream:
self.commands.put(line)
def poll(self):
try:
return self.commands.get_nowait()
except Empty:
return None
def help_text():
return (
"Control: mode joints|cartesian; then set <values>; "
"bare values also work after mode; "
"or use joints <A1..A7> / cartesian <x y z roll pitch yaw>; "
"angles radians, position metres, Cartesian frame = robot base; quit"
)
class RobotController:
"""Apply joint targets or base-frame TCP targets to a built Genesis robot."""
def __init__(self, robot, dofs_idx_local, robot_cfg, tcp_link="link7"):
self.robot = robot
self.dofs_idx_local = dofs_idx_local
self.robot_cfg = robot_cfg
self.tcp_link = tcp_link
self.mode = "joints"
def apply(self, command):
if isinstance(command, ModeCommand):
if command.mode in ("joints", "cartesian"):
self.mode = command.mode
return
if isinstance(command, JointCommand):
self.mode = "joints"
self.robot.control_dofs_position(
np.asarray(command.angles), dofs_idx_local=self.dofs_idx_local)
return
if isinstance(command, CartesianCommand):
self.mode = "cartesian"
pose = base_pose_to_world(
self.robot_cfg["position"], self.robot_cfg["euler"],
command.position, command.rpy)
quat = Rotation.from_matrix(pose[:3, :3]).as_quat(scalar_first=True)
link = self.robot.get_link(self.tcp_link)
qpos = self.robot.inverse_kinematics(
link=link, pos=pose[:3, 3], quat=quat,
dofs_idx_local=self.dofs_idx_local)
self.robot.control_dofs_position(qpos, dofs_idx_local=self.dofs_idx_local)
return
if isinstance(command, QuitCommand):
return
raise TypeError(f"Unsupported command: {type(command).__name__}")
+183
View File
@@ -0,0 +1,183 @@
"""Tkinter control window kept separate from the Genesis process."""
import multiprocessing as mp
import math
from queue import Empty
def command_from_fields(mode, values, speed_percent):
"""Convert GUI strings into a picklable command dictionary."""
expected = {"joints": 7, "cartesian": 6}.get(mode)
if expected is None:
raise ValueError("mode must be joints or cartesian")
if len(values) != expected:
raise ValueError(f"{mode} expects {expected} values")
try:
numbers = [float(value) for value in values]
speed = float(speed_percent)
except (TypeError, ValueError) as exc:
raise ValueError("GUI values must be numeric") from exc
if not all(math.isfinite(value) for value in numbers + [speed]):
raise ValueError("GUI values must be finite")
if not 0 <= speed <= 100:
raise ValueError("speed_percent must be between 0 and 100")
if speed == 0:
return {"type": "stop"}
return {"type": mode, "values": numbers, "speed_percent": speed}
def _observe_parent_close(root, closed):
"""Close a Tk root cooperatively when the parent sets its event."""
if closed.is_set():
root.destroy()
else:
root.after(50, lambda: _observe_parent_close(root, closed))
def _run_control_window(queue, closed, status, diagnostics):
def fail(message):
status.value = 3
try:
diagnostics.put({"status": "failed", "message": message})
except (OSError, ValueError):
pass
try:
import tkinter as tk
from tkinter import messagebox, ttk
except (ImportError, OSError) as exc:
fail(str(exc))
closed.set()
return
try:
status.value = 1
root = tk.Tk()
root.title("iiwa control")
mode = tk.StringVar(value="joints")
speed = tk.DoubleVar(value=50)
joint_entries = []
cartesian_entries = []
ttk.Label(root, text="Mode").grid(row=0, column=0, sticky="w")
selector = ttk.Combobox(root, textvariable=mode, values=("joints", "cartesian"),
state="readonly", width=12)
selector.grid(row=0, column=1, columnspan=3, sticky="ew")
ttk.Label(root, text="Joints (rad)").grid(row=1, column=0, sticky="w")
for index in range(7):
entry = ttk.Entry(root, width=9)
entry.insert(0, "0")
entry.grid(row=2, column=index, padx=2, pady=2)
joint_entries.append(entry)
ttk.Label(root, text="Cartesian (m, rad: x y z roll pitch yaw)").grid(
row=3, column=0, columnspan=7, sticky="w")
for index in range(6):
entry = ttk.Entry(root, width=9)
entry.insert(0, "0")
entry.grid(row=4, column=index, padx=2, pady=2)
cartesian_entries.append(entry)
ttk.Label(root, text="Speed").grid(row=5, column=0, sticky="w")
ttk.Scale(root, from_=0, to=100, variable=speed, orient="horizontal").grid(
row=5, column=1, columnspan=5, sticky="ew")
ttk.Label(root, textvariable=speed).grid(row=5, column=6)
def send():
entries = joint_entries if mode.get() == "joints" else cartesian_entries
try:
command = command_from_fields(mode.get(), [entry.get() for entry in entries], speed.get())
except ValueError as exc:
messagebox.showerror("Invalid command", str(exc), parent=root)
return
try:
queue.put(command)
except (OSError, ValueError):
closed.set()
root.destroy()
def stop():
try:
queue.put({"type": "stop"})
except (OSError, ValueError):
closed.set()
root.destroy()
def close():
try:
queue.put({"type": "close"})
except (OSError, ValueError):
pass
closed.set()
root.destroy()
ttk.Button(root, text="Send", command=send).grid(row=6, column=0, columnspan=2, pady=6)
ttk.Button(root, text="Stop", command=stop).grid(row=6, column=2, columnspan=2, pady=6)
ttk.Button(root, text="Close", command=close).grid(row=6, column=4, columnspan=2, pady=6)
root.protocol("WM_DELETE_WINDOW", close)
root.after(50, lambda: _observe_parent_close(root, closed))
root.mainloop()
except tk.TclError as exc:
fail(str(exc))
finally:
closed.set()
if status.value != 3:
status.value = 2
class ControlGui:
"""Own the independent Tkinter process and its command queue."""
def __init__(self):
context = mp.get_context("spawn")
self.queue = context.Queue()
self.commands = self.queue
self.closed = context.Event()
self._status = context.Value("i", 0)
self.diagnostics = context.Queue()
self.process = context.Process(target=_run_control_window,
args=(self.queue, self.closed, self._status, self.diagnostics),
daemon=True)
self._started = False
def start(self):
try:
self.process.start()
self._started = True
except (OSError, RuntimeError):
self._status.value = 3
self.closed.set()
return self
@property
def status(self):
return {0: "created", 1: "running", 2: "closed", 3: "failed"}.get(
self._status.value, "failed")
def poll(self):
try:
return self.queue.get(timeout=0.05)
except (Empty, OSError, ValueError):
return None
def poll_diagnostic(self):
try:
return self.diagnostics.get(timeout=0.05)
except (Empty, OSError, ValueError):
return None
def close(self):
self.closed.set()
if self._started and self.process.is_alive():
self.process.join(timeout=2)
if self._started and self.process.is_alive():
self.process.terminate()
self.process.join(timeout=2)
self.queue.cancel_join_thread()
self.queue.close()
self.diagnostics.cancel_join_thread()
self.diagnostics.close()
self._status.value = 2 if self.status != "failed" else 3
GuiControl = ControlGui
+150 -3
View File
@@ -1,11 +1,159 @@
from pathlib import Path
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from .camera import D455 from .camera import D455
from .config import DESCRIPTION from .config import DESCRIPTION
from .control import (
CartesianCommand,
JointCommand,
ModeCommand,
VelocityLimitedMotion,
base_pose_to_world,
configure_robot_control,
)
IK_POSITION_TOLERANCE = 5e-4
IK_ROTATION_TOLERANCE = 5e-3
# Genesis returns float32 joint values; allow sub-microradian boundary noise.
IK_JOINT_LIMIT_TOLERANCE = 1e-6
def load_joint_limits(urdf_path):
"""Read position and velocity limits in joint1 through joint7 order."""
joints = {
joint.get("name"): joint
for joint in ET.parse(Path(urdf_path)).getroot().findall("joint")
if joint.get("type") == "revolute"
}
try:
limits = np.asarray([
[float(joints[f"joint{index}"].find("limit").get(name))
for index in range(1, 8)]
for name in ("lower", "upper", "velocity")
])
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise ValueError("URDF must define lower, upper and velocity limits for joint1 through joint7") from exc
lower, upper, velocity = limits
if not np.isfinite(limits).all() or (lower > upper).any() or (velocity <= 0).any():
raise ValueError("URDF limits must be finite, ordered and have positive velocities")
return lower, upper, velocity
def load_joint_velocity_limits(urdf_path):
return load_joint_limits(urdf_path)[2]
def _as_numpy(value):
"""Move a Genesis tensor to CPU before converting to NumPy."""
detach = getattr(value, "detach", None)
if detach is not None:
value = detach()
cpu = getattr(value, "cpu", None)
if cpu is not None:
value = cpu()
numpy = getattr(value, "numpy", None)
if numpy is not None:
value = numpy()
return np.asarray(value, dtype=float)
def _as_joint_vector(value):
result = _as_numpy(value)
if result.shape != (7,) or not np.isfinite(result).all():
raise ValueError("Genesis returned an invalid seven-joint target")
return result
class SceneMotionController:
"""Plan interactive commands and send one velocity-limited target per step."""
def __init__(self, robot, dofs_idx_local, cfg):
self.robot = robot
self.dofs_idx_local = list(dofs_idx_local)
self.robot_cfg = cfg["robot"]
self.tcp_link = "tcp" if cfg["scene"]["mode"] == "with_gripper" else "link7"
self.lower, self.upper, velocity = load_joint_limits(self.robot_cfg["urdf"])
self.motion = VelocityLimitedMotion(velocity)
self.mode = "joints"
self.dt = cfg["scene"]["dt"]
self.commanded_q = self.current_q().copy()
self.motion.stop(self.commanded_q)
def current_q(self):
return _as_joint_vector(self.robot.get_dofs_position(dofs_idx_local=self.dofs_idx_local))
def validate_target(self, value, tolerance=0):
target = _as_joint_vector(value)
if ((target < self.lower - tolerance) | (target > self.upper + tolerance)).any():
raise ValueError("Joint target is outside URDF position limits")
return np.clip(target, self.lower, self.upper)
def apply(self, command, speed_percent=100):
if isinstance(command, ModeCommand):
if command.mode in ("joints", "cartesian"):
self.mode = command.mode
return
if isinstance(command, JointCommand):
self.motion.set_target(self.validate_target(command.angles), speed_percent)
self.mode = "joints"
return
if isinstance(command, CartesianCommand):
pose = base_pose_to_world(
self.robot_cfg["position"], self.robot_cfg["euler"],
command.position, command.rpy)
quat = Rotation.from_matrix(pose[:3, :3]).as_quat(scalar_first=True)
try:
qpos, residual = self.robot.inverse_kinematics(
link=self.robot.get_link(self.tcp_link), pos=pose[:3, 3], quat=quat,
dofs_idx_local=self.dofs_idx_local, return_error=True,
pos_tol=IK_POSITION_TOLERANCE, rot_tol=IK_ROTATION_TOLERANCE)
except Exception as exc:
# GenesisException derives directly from Exception.
raise ValueError(f"Cartesian IK failed: {exc}") from exc
residual = _as_numpy(residual)
if (residual.shape != (6,) or not np.isfinite(residual).all()
or np.max(np.abs(residual[:3])) > IK_POSITION_TOLERANCE
or np.max(np.abs(residual[3:])) > IK_ROTATION_TOLERANCE):
raise ValueError(
"Cartesian IK did not converge within pose tolerances "
f"(position={residual[:3]}, rotation={residual[3:]})")
self.motion.set_target(
self.validate_target(qpos, IK_JOINT_LIMIT_TOLERANCE), speed_percent)
self.mode = "cartesian"
return
raise TypeError(f"Unsupported command: {type(command).__name__}")
def apply_gui(self, command):
command_type = command.get("type")
if command_type == "stop":
self.motion.stop(self.commanded_q)
return
if command_type == "joints":
self.apply(JointCommand(tuple(command["values"])), command["speed_percent"])
return
if command_type == "cartesian":
values = command["values"]
self.apply(CartesianCommand(tuple(values[:3]), tuple(values[3:])),
command["speed_percent"])
return
if command_type == "close":
return
raise ValueError("unknown GUI command")
def step(self):
position, complete = self.motion.step(self.commanded_q, self.dt)
self.robot.control_dofs_position(position, dofs_idx_local=self.dofs_idx_local)
self.commanded_q = position.copy()
return complete
def build_scene(gs, cfg, viewer): def build_scene(gs, cfg, viewer):
scene = gs.Scene( scene = gs.Scene(
show_viewer=viewer, show_viewer=viewer,
# v1 is a pose inspection scene: no falling arm and no active controller. # Zero gravity supports pose inspection with bounded position control.
sim_options=gs.options.SimOptions(dt=cfg["scene"]["dt"], gravity=(0, 0, 0)), sim_options=gs.options.SimOptions(dt=cfg["scene"]["dt"], gravity=(0, 0, 0)),
viewer_options=gs.options.ViewerOptions(camera_pos=(2.8, -3, 2.6), camera_lookat=(0, 0, 1.1)), viewer_options=gs.options.ViewerOptions(camera_pos=(2.8, -3, 2.6), camera_lookat=(0, 0, 1.1)),
vis_options=gs.options.VisOptions(show_world_frame=True), vis_options=gs.options.VisOptions(show_world_frame=True),
@@ -20,8 +168,6 @@ def build_scene(gs, cfg, viewer):
robot = entity robot = entity
# Reuse the supplied D455 CAD mesh. Its lens face is at mesh Z=0; body extends +Z. # Reuse the supplied D455 CAD mesh. Its lens face is at mesh Z=0; body extends +Z.
from .camera import optical_pose from .camera import optical_pose
from scipy.spatial.transform import Rotation
import numpy as np
overhead = cfg["cameras"]["overhead"] overhead = cfg["cameras"]["overhead"]
pose = optical_pose(overhead["position"], overhead["lookat"], overhead["up"]) pose = optical_pose(overhead["position"], overhead["lookat"], overhead["up"])
mesh_pos = pose[:3, 3] + pose[:3, :3] @ [-0.062, 0.0155, -0.001] mesh_pos = pose[:3, 3] + pose[:3, :3] @ [-0.062, 0.0155, -0.001]
@@ -35,5 +181,6 @@ def build_scene(gs, cfg, viewer):
indices = [robot.get_joint(f"joint{i}").dofs_idx_local[0] for i in range(1, 8)] indices = [robot.get_joint(f"joint{i}").dofs_idx_local[0] for i in range(1, 8)]
if robot.n_dofs != 7: if robot.n_dofs != 7:
raise ValueError(f"Expected seven robot DOFs, got {robot.n_dofs}") raise ValueError(f"Expected seven robot DOFs, got {robot.n_dofs}")
configure_robot_control(robot, indices)
robot.set_dofs_position([cfg["robot"]["joints"][f"joint{i}"] for i in range(1, 8)], dofs_idx_local=indices) robot.set_dofs_position([cfg["robot"]["joints"][f"joint{i}"] for i in range(1, 8)], dofs_idx_local=indices)
return scene, robot, cameras return scene, robot, cameras
+800
View File
@@ -0,0 +1,800 @@
import numpy as np
import pytest
import pickle
from multiprocessing import get_context
from queue import Empty, Queue
from types import SimpleNamespace
import xml.etree.ElementTree as ET
from iiwa_genesis.control import (
CartesianCommand,
JointCommand,
RobotController,
VelocityLimitedMotion,
configure_robot_control,
base_pose_to_world,
parse_command,
GuiCommandQueue,
)
from iiwa_genesis.control_gui import ControlGui, _observe_parent_close, command_from_fields
from iiwa_genesis.scene import SceneMotionController
IIWA_VELOCITY_LIMITS = (1.71, 1.71, 1.75, 2.27, 2.44, 3.14, 3.14)
class FakeRobot:
def __init__(self):
self.calls = []
self.position = np.zeros(7)
def control_dofs_position(self, position, dofs_idx_local=None):
self.position = np.asarray(position, dtype=float)
self.calls.append(("control", self.position.copy(), dofs_idx_local))
def get_dofs_position(self, dofs_idx_local=None):
self.calls.append(("position", dofs_idx_local))
return self.position
def get_link(self, name):
self.calls.append(("link", name))
return name
def inverse_kinematics(self, **kwargs):
self.calls.append(("ik", kwargs))
qpos = np.arange(7, dtype=float) * .2
return (qpos, np.zeros(6)) if kwargs.get("return_error") else qpos
def set_dofs_kp(self, kp, dofs_idx_local=None):
self.calls.append(("kp", np.asarray(kp), dofs_idx_local))
def set_dofs_kv(self, kv, dofs_idx_local=None):
self.calls.append(("kv", np.asarray(kv), dofs_idx_local))
def set_dofs_force_range(self, lower, upper, dofs_idx_local=None):
self.calls.append(("force", np.asarray(lower), np.asarray(upper), dofs_idx_local))
def test_parses_joint_mode_and_seven_angles():
command = parse_command("joints 0 0.2 0 1.0 0 -0.3 0.1")
assert isinstance(command, JointCommand)
np.testing.assert_allclose(command.angles, [0, 0.2, 0, 1.0, 0, -0.3, 0.1])
def test_set_uses_all_seven_values_after_selecting_joints_mode():
command = parse_command("set 0 0.2 0 1.0 0 0 0", mode="joints")
assert isinstance(command, JointCommand)
np.testing.assert_allclose(command.angles, [0, 0.2, 0, 1.0, 0, 0, 0])
def test_selected_joints_mode_accepts_bare_angle_values():
command = parse_command("0 0.2 0 1.0 0 0 0", mode="joints")
assert isinstance(command, JointCommand)
np.testing.assert_allclose(command.angles, [0, 0.2, 0, 1.0, 0, 0, 0])
def test_parses_cartesian_tcp_pose_in_robot_base_frame():
command = parse_command("cartesian 0.35 0 0.55 0 1.57 0")
assert isinstance(command, CartesianCommand)
np.testing.assert_allclose(command.position, [0.35, 0, 0.55])
np.testing.assert_allclose(command.rpy, [0, 1.57, 0])
def test_cartesian_target_is_transformed_from_robot_base_to_world():
pose = base_pose_to_world([1, 2, 3], [0, 0, 90], [0.5, 0, 0.2], [0, 0, 0])
np.testing.assert_allclose(pose[:3, 3], [1, 2.5, 3.2], atol=1e-7)
@pytest.mark.parametrize("line", ["joints 0 0 0", "cartesian 0 0 0 0", "mode unknown"])
def test_rejects_invalid_commands(line):
with pytest.raises(ValueError):
parse_command(line)
def test_controller_sends_joint_target_to_position_controller():
robot = FakeRobot()
controller = RobotController(robot, [1, 2, 3, 4, 5, 6, 7],
{"position": [1, 2, 3], "euler": [0, 0, 0]})
controller.apply(JointCommand((0, 1, 2, 3, 4, 5, 6)))
assert robot.calls[0][0] == "control"
np.testing.assert_array_equal(robot.calls[0][1], [0, 1, 2, 3, 4, 5, 6])
def test_configures_position_controller_for_all_arm_dofs():
robot = FakeRobot()
configure_robot_control(robot, [1, 2, 3, 4, 5, 6, 7])
assert [call[0] for call in robot.calls] == ["kp", "kv", "force"]
def test_controller_sends_cartesian_target_to_ik_in_world_frame():
robot = FakeRobot()
controller = RobotController(robot, list(range(7)),
{"position": [1, 2, 3], "euler": [0, 0, 0]}, "tcp")
controller.apply(CartesianCommand((0.5, 0, 0.2), (0, 0, 0)))
ik = next(call[1] for call in robot.calls if call[0] == "ik")
np.testing.assert_allclose(ik["pos"], [1.5, 2, 3.2])
assert ik["link"] == "tcp"
def test_cartesian_ik_accepts_componentwise_genesis_tolerances():
robot = FakeRobot()
robot.inverse_kinematics = lambda **kwargs: (np.zeros(7), np.full(6, 0.0004))
controller = SceneMotionController(robot, list(range(7)), motion_config())
controller.apply(CartesianCommand((0.5, 0, 0.2), (0, 0, 0)), 50)
def test_scene_motion_controller_plans_gui_cartesian_command_at_selected_speed():
robot = FakeRobot()
controller = SceneMotionController(
robot,
list(range(7)),
{
"scene": {"mode": "with_gripper", "dt": 0.1},
"robot": {
"urdf": "iiwa_genesis_description/urdf/iiwa_with_gripper.urdf",
"position": [1, 2, 3],
"euler": [0, 0, 0],
},
},
)
controller.apply_gui({
"type": "cartesian", "values": [0.5, 0, 0.2, 0, 0, 0],
"speed_percent": 50,
})
controller.step()
ik = next(call[1] for call in robot.calls if call[0] == "ik")
np.testing.assert_allclose(ik["pos"], [1.5, 2, 3.2])
assert ik["link"] == "tcp"
np.testing.assert_allclose(
robot.calls[-1][1], [0, .0855, .0875, .1135, .122, .157, .157])
class LaggingRobot(FakeRobot):
def control_dofs_position(self, position, dofs_idx_local=None):
self.calls.append(("control", np.asarray(position).copy(), dofs_idx_local))
def motion_config(mode="with_gripper"):
return {
"scene": {"mode": mode, "dt": .1},
"robot": {
"urdf": f"iiwa_genesis_description/urdf/iiwa_{mode}.urdf",
"position": [1, 2, 3], "euler": [0, 0, 90],
},
}
@pytest.mark.parametrize("mode,link", [("with_gripper", "tcp"), ("without_gripper", "link7")])
def test_queued_cartesian_motion_advances_despite_lagging_feedback(mode, link):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config(mode))
commands = Queue()
commands.put({"type": "cartesian", "values": [.5, 0, .2, 0, 0, 0],
"speed_percent": 50})
controller.apply_gui(commands.get_nowait())
for _ in range(3):
controller.step()
targets = [call[1] for call in robot.calls if call[0] == "control"]
np.testing.assert_allclose(targets[1], [0, .171, .175, .227, .244, .314, .314])
np.testing.assert_allclose(targets[2], [0, .2, .2625, .3405, .366, .471, .471])
ik = next(call[1] for call in robot.calls if call[0] == "ik")
assert ik["link"] == link
assert ik["return_error"] is True
assert ik["pos_tol"] == .0005
assert ik["rot_tol"] == .005
np.testing.assert_allclose(ik["pos"], [1, 2.5, 3.2])
np.testing.assert_allclose(ik["quat"], [2**-.5, 0, 0, 2**-.5], atol=1e-7)
def test_queued_stop_freezes_commanded_target_and_next_move_starts_there():
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config())
commands = Queue()
commands.put({"type": "joints", "values": [1] * 7, "speed_percent": 50})
controller.apply_gui(commands.get_nowait())
controller.step()
controller.step()
frozen = np.array([.171, .171, .175, .227, .244, .314, .314])
commands.put({"type": "stop"})
controller.apply_gui(commands.get_nowait())
for _ in range(3):
assert controller.step() is True
np.testing.assert_allclose(robot.calls[-1][1], frozen)
controller.apply(JointCommand((0,) * 7))
controller.step()
np.testing.assert_allclose(robot.calls[-1][1], np.zeros(7), atol=1e-15)
@pytest.mark.parametrize("target", [[4] * 7, [-4] * 7, [0] * 6, [np.nan] * 7, [np.inf] * 7])
@pytest.mark.parametrize("mode", ["with_gripper", "without_gripper"])
def test_invalid_joint_target_preserves_active_motion(target, mode):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config(mode))
controller.apply(JointCommand((1,) * 7), 50)
controller.step()
with pytest.raises(ValueError):
controller.apply_gui({"type": "joints", "values": target, "speed_percent": 100})
controller.step()
np.testing.assert_allclose(robot.calls[-1][1], [.171, .171, .175, .227, .244, .314, .314])
@pytest.mark.parametrize("index", range(7))
@pytest.mark.parametrize("boundary", [-.25, .5])
def test_selected_urdf_position_limits_apply_to_each_joint(tmp_path, index, boundary):
cfg = motion_config()
tree = ET.parse(cfg["robot"]["urdf"])
limit = tree.find(f"joint[@name='joint{index + 1}']/limit")
limit.set("lower", "-.25")
limit.set("upper", ".5")
cfg["robot"]["urdf"] = tmp_path / "robot.urdf"
tree.write(cfg["robot"]["urdf"])
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), cfg)
target = np.zeros(7)
target[index] = boundary
controller.apply(JointCommand(tuple(target)))
for _ in range(4):
controller.step()
np.testing.assert_allclose(robot.calls[-1][1], target)
invalid = target.copy()
invalid[index] += -.001 if boundary < 0 else .001
with pytest.raises(ValueError, match="URDF"):
controller.apply(JointCommand(tuple(invalid)))
assert controller.step() is True
np.testing.assert_allclose(robot.calls[-1][1], target)
@pytest.mark.parametrize("result", [
(np.zeros(7), [.0006, 0, 0, 0, 0, 0]),
(np.zeros(7), [0, 0, 0, .006, 0, 0]),
(np.zeros(7), [np.nan] * 6),
(np.zeros(7), [np.inf] * 6),
(np.zeros(7), [0] * 5),
(np.full(7, 4), np.zeros(6)),
(np.full(7, np.nan), np.zeros(6)),
Exception("Genesis solver failed"),
])
def test_failed_ik_is_recoverable_and_preserves_active_motion(result, monkeypatch):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config())
controller.apply(JointCommand((1,) * 7), 50)
controller.step()
def inverse_kinematics(**kwargs):
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(robot, "inverse_kinematics", inverse_kinematics)
with pytest.raises(ValueError):
controller.apply(CartesianCommand((100, 0, 0), (0, 0, 0)))
assert controller.mode == "joints"
controller.step()
np.testing.assert_allclose(robot.calls[-1][1], [.171, .171, .175, .227, .244, .314, .314])
@pytest.fixture
def cli_runtime(monkeypatch, tmp_path):
from iiwa_genesis import cli, control_gui, preview, runtime, scene
robot = LaggingRobot()
robot.get_joint = lambda name: SimpleNamespace(dofs_idx_local=[int(name[-1]) - 1])
terminal_queue = Queue()
gui = SimpleNamespace(commands=Queue(), diagnostics=Queue(), status="running")
events = []
scheduled = {}
def poll_terminal():
try:
return terminal_queue.get_nowait()
except Empty:
return None
terminal = SimpleNamespace(start=lambda: None, poll=poll_terminal)
gui.start = lambda: gui
gui.close = lambda: events.append("gui closed")
fake_preview = SimpleNamespace(close=lambda: events.append("preview closed"),
stop=SimpleNamespace(is_set=lambda: False),
submit=lambda *args: None)
steps = []
def step():
steps.append(robot.calls[-1][1].copy())
if len(steps) in scheduled:
scheduled[len(steps)]()
fake_scene = SimpleNamespace(step=step, viewer=SimpleNamespace(is_alive=lambda: True))
camera = SimpleNamespace(name="overhead", cfg={"fps": 10, "max_range": 6},
read=lambda timestamp: events.append(("frame", timestamp)))
gs = SimpleNamespace(cpu="cpu", backend="cpu", __version__="fake",
init=lambda **kwargs: None, destroy=lambda: events.append("destroy"))
cfg = motion_config()
cfg["scene"].update(viewer=False, backend="cpu", seed=0)
cfg["output"] = dict(save_frames=False, preview=True, directory=tmp_path, pointcloud=False)
monkeypatch.setattr(cli, "load_config", lambda path: cfg)
monkeypatch.setattr(runtime, "import_genesis", lambda: gs)
monkeypatch.setattr(scene, "build_scene", lambda *args: (fake_scene, robot, [camera]))
monkeypatch.setattr(cli, "TerminalInput", lambda: terminal)
monkeypatch.setattr(control_gui, "ControlGui", lambda: gui)
monkeypatch.setattr(preview, "Preview", lambda: fake_preview)
monkeypatch.setattr(cli.time, "sleep", lambda duration: None)
monkeypatch.setattr("sys.argv", ["run_scene.py", "--viewer", "--steps", "3",
"--interactive", "--control-window"])
return SimpleNamespace(run=cli.main, robot=robot, gui=gui, terminal=terminal_queue,
events=events, steps=steps, scheduled=scheduled, preview=fake_preview)
@pytest.mark.parametrize("mode", ["with_gripper", "without_gripper"])
@pytest.mark.parametrize("side", ["lower", "upper"])
@pytest.mark.parametrize("outward", [False, True])
def test_cartesian_ik_clips_float32_joint_boundaries(mode, side, outward, monkeypatch):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config(mode))
boundary = getattr(controller, side).copy()
result = boundary.astype(np.float32)
if outward:
result = np.nextafter(result, np.float32(-np.inf if side == "lower" else np.inf))
monkeypatch.setattr(robot, "inverse_kinematics", lambda **kwargs: (result, np.zeros(6)))
controller.apply(CartesianCommand((.5, 0, .2), (0, 0, 0)))
for _ in range(40):
controller.step()
expected = np.array([2.97, 2.09, 2.97, 2.09, 2.97, 2.09, 3.05] if outward else [
2.97, 2.0899999141693115, 2.97, 2.0899999141693115,
2.97, 2.0899999141693115, 3.049999952316284])
if side == "lower":
expected = -expected
np.testing.assert_array_equal(robot.calls[-1][1], expected)
assert (robot.calls[-1][1] >= controller.lower).all()
assert (robot.calls[-1][1] <= controller.upper).all()
np.testing.assert_array_equal(getattr(controller, side), boundary)
@pytest.mark.parametrize("index", range(7))
@pytest.mark.parametrize("side,offset", [("lower", -1e-5), ("upper", 1e-5)])
def test_cartesian_ik_rejects_real_boundary_violation(index, side, offset, monkeypatch):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config())
controller.apply(JointCommand((1,) * 7), 50)
controller.step()
result = np.zeros(7)
result[index] = getattr(controller, side)[index] + offset
monkeypatch.setattr(robot, "inverse_kinematics", lambda **kwargs: (result, np.zeros(6)))
with pytest.raises(ValueError, match="URDF"):
controller.apply(CartesianCommand((.5, 0, .2), (0, 0, 0)))
assert controller.mode == "joints"
controller.step()
np.testing.assert_allclose(robot.calls[-1][1], [.171, .171, .175, .227, .244, .314, .314])
class SustainedQueue:
def __init__(self, app, value):
self.app = app
self.value = value
self.reads = {}
def get_nowait(self):
tick = len(self.app.steps)
self.reads[tick] = self.reads.get(tick, 0) + 1
assert self.reads[tick] < 1000, "input processing starved physics"
return self.value
@pytest.mark.parametrize("channel", ["terminal", "commands", "diagnostics"])
def test_cli_sustained_input_yields_to_physics_and_frames(cli_runtime, channel, monkeypatch):
from iiwa_genesis import cli
app = cli_runtime
value = "joints 1 1 1 1 1 1 1" if channel == "terminal" else {
"type": "joints", "values": [1] * 7, "speed_percent": 100}
queue = SustainedQueue(app, value)
if channel == "terminal":
monkeypatch.setattr(cli, "TerminalInput", lambda: SimpleNamespace(
start=lambda: None, poll=queue.get_nowait))
else:
setattr(app.gui, channel, queue)
app.terminal.put("joints 1 1 1 1 1 1 1")
app.run()
assert len(app.steps) == 3
assert set(queue.reads) == {0, 1, 2}
assert len([event for event in app.events if isinstance(event, tuple)]) == 3
assert (app.steps[-1] > app.steps[0]).all()
@pytest.mark.parametrize("quit_line", ["quit", " EXIT "])
def test_cli_quit_exits_before_pending_movement_gui_or_physics(cli_runtime, quit_line):
app = cli_runtime
app.terminal.put("joints 1 1 1 1 1 1 1")
def request_quit():
app.terminal.put("cartesian .5 0 .2 0 0 0")
app.terminal.put(quit_line)
app.gui.commands.put({"type": "joints", "values": [-1] * 7, "speed_percent": 100})
app.gui.diagnostics.put({"message": "pending diagnostic"})
app.scheduled[1] = request_quit
app.run()
assert len(app.steps) == 1
assert not any(call[0] == "ik" for call in app.robot.calls)
assert app.gui.commands.qsize() == 1
assert app.gui.diagnostics.qsize() == 1
assert len([event for event in app.events if isinstance(event, tuple)]) == 1
assert app.events[-3:] == ["gui closed", "preview closed", "destroy"]
def test_cli_stop_preempts_pending_ik_from_both_inputs_and_allows_later_move(cli_runtime):
app = cli_runtime
app.terminal.put("cartesian .5 0 .2 0 0 0")
app.gui.commands.put({"type": "cartesian", "values": [.5, 0, .2, 0, 0, 0],
"speed_percent": 100})
app.gui.commands.put({"type": "stop"})
app.scheduled[1] = lambda: app.gui.commands.put({
"type": "joints", "values": [1] * 7, "speed_percent": 50})
app.run()
assert not any(call[0] == "ik" for call in app.robot.calls)
np.testing.assert_array_equal(app.steps[0], np.zeros(7))
np.testing.assert_allclose(app.steps[1], [.0855, .0855, .0875, .1135, .122, .157, .157])
def test_cli_stop_defers_already_queued_later_move_until_next_tick(cli_runtime):
app = cli_runtime
app.gui.commands.put({"type": "stop"})
app.gui.commands.put({"type": "joints", "values": [1] * 7, "speed_percent": 50})
app.run()
np.testing.assert_array_equal(app.steps[0], np.zeros(7))
np.testing.assert_allclose(app.steps[1], [.0855, .0855, .0875, .1135, .122, .157, .157])
np.testing.assert_allclose(app.steps[2], [.171, .171, .175, .227, .244, .314, .314])
@pytest.mark.parametrize("source", ["terminal", "gui"])
def test_cli_bounded_batches_preserve_backlogged_commands(cli_runtime, source, monkeypatch):
app = cli_runtime
for _ in range(100):
if source == "terminal":
app.terminal.put("help")
else:
app.gui.commands.put({"type": "close"})
if source == "terminal":
app.terminal.put("joints 1 1 1 1 1 1 1")
else:
app.gui.commands.put({"type": "joints", "values": [1] * 7, "speed_percent": 100})
# Run enough ticks to reach the final command while checking frames continue.
monkeypatch.setattr("sys.argv", ["run_scene.py", "--viewer", "--steps", "12",
"--interactive", "--control-window"])
app.run()
np.testing.assert_array_equal(app.steps[0], np.zeros(7))
assert (app.steps[-1] > 0).all()
assert len(app.steps) == 12
def test_cli_batch_parses_set_after_mode_change_and_invalid_quit(cli_runtime, capsys):
app = cli_runtime
app.terminal.put("quit extra")
app.terminal.put("mode cartesian")
app.terminal.put("set .5 0 .2 0 0 0")
app.run()
assert len(app.steps) == 3
assert len([call for call in app.robot.calls if call[0] == "ik"]) == 1
assert "quit takes no values" in capsys.readouterr().out
@pytest.mark.parametrize("side,offset", [("lower", -1e-7), ("upper", 1e-7)])
def test_joint_commands_keep_strict_limits_for_tiny_excursions(side, offset):
robot = LaggingRobot()
controller = SceneMotionController(robot, list(range(7)), motion_config())
target = getattr(controller, side) + offset
with pytest.raises(ValueError, match="URDF"):
controller.apply(JointCommand(tuple(target)))
class BrokenQueue:
def __init__(self, error):
self.error = error
self.reads = 0
def get_nowait(self):
self.reads += 1
raise self.error("queue disconnected")
@pytest.mark.parametrize("channel", ["commands", "diagnostics"])
@pytest.mark.parametrize("error", [EOFError, OSError, ValueError])
def test_cli_disables_only_broken_gui_queue_and_keeps_terminal_and_frames(cli_runtime, channel, error, capsys):
app = cli_runtime
broken = BrokenQueue(error)
setattr(app.gui, channel, broken)
app.terminal.put("joints 1 1 1 1 1 1 1")
app.scheduled[1] = lambda: app.terminal.put("joints -1 -1 -1 -1 -1 -1 -1")
if channel == "diagnostics":
app.scheduled[2] = lambda: app.gui.commands.put({"type": "stop"})
else:
app.gui.diagnostics.put({"message": "child startup failed"})
app.run()
assert broken.reads == 1
assert len(app.steps) == 3
np.testing.assert_allclose(app.steps[1], np.zeros(7), atol=1e-15)
if channel == "diagnostics":
np.testing.assert_allclose(app.steps[2], app.steps[1])
else:
assert (app.steps[2] < 0).all()
assert len([event for event in app.events if isinstance(event, tuple)]) == 3
assert app.events[-3:] == ["gui closed", "preview closed", "destroy"]
assert "queue disconnected" in capsys.readouterr().out
def test_cli_rejects_gui_command_but_processes_following_valid_command(cli_runtime, capsys):
app = cli_runtime
app.gui.commands.put({"type": "joints", "values": [4] * 7, "speed_percent": 100})
app.gui.commands.put({"type": "joints", "values": [1] * 7, "speed_percent": 50})
app.run()
np.testing.assert_allclose(app.steps[0], [.0855, .0855, .0875, .1135, .122, .157, .157])
np.testing.assert_allclose(app.steps[2], [.2565, .2565, .2625, .3405, .366, .471, .471])
assert "Command error" in capsys.readouterr().out
@pytest.mark.parametrize("source", ["terminal", "gui"])
def test_cli_recovers_from_genesis_ik_exception_without_losing_motion(cli_runtime, source, monkeypatch, capsys):
app = cli_runtime
def fail(**kwargs):
raise Exception("Genesis IK failure")
monkeypatch.setattr(app.robot, "inverse_kinematics", fail)
app.gui.commands.put({"type": "joints", "values": [1] * 7, "speed_percent": 50})
if source == "terminal":
app.scheduled[1] = lambda: app.terminal.put("cartesian 100 0 0 0 0 0")
else:
app.scheduled[1] = lambda: app.gui.commands.put({
"type": "cartesian", "values": [100, 0, 0, 0, 0, 0], "speed_percent": 100})
app.scheduled[2] = lambda: app.gui.commands.put({"type": "stop"})
app.run()
np.testing.assert_allclose(app.steps[1], [.171, .171, .175, .227, .244, .314, .314])
np.testing.assert_allclose(app.steps[2], app.steps[1])
assert app.events[-1] == "destroy"
output = capsys.readouterr().out
assert "Command error" in output
assert "Genesis IK failure" in output
@pytest.mark.parametrize("failure", ["startup", "close", "preview_close"])
def test_cli_gui_failures_always_destroy_genesis(cli_runtime, failure, capsys):
app = cli_runtime
def fail():
raise RuntimeError(failure)
if failure == "startup":
app.gui.start = fail
elif failure == "close":
app.gui.close = fail
else:
app.preview.close = fail
app.terminal.put("joints 1 1 1 1 1 1 1")
app.run()
assert len(app.steps) == 3
assert app.events[-1] == "destroy"
if failure != "preview_close":
assert "preview closed" in app.events
assert failure in capsys.readouterr().out
def test_velocity_limited_motion_scales_each_urdf_velocity_limit():
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
motion.set_target(np.full(7, 10.0), 50)
position, complete = motion.step(np.zeros(7), 1.0)
np.testing.assert_allclose(position, np.asarray(IIWA_VELOCITY_LIMITS) * 0.5)
assert complete is False
@pytest.mark.parametrize(
"speed_percent", [np.nan, np.inf, -1, 0, 101, "50", "fast", True,
np.bool_(False), 1 + 0j, None])
def test_velocity_limited_motion_rejects_invalid_speed(speed_percent):
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
with pytest.raises(ValueError):
motion.set_target(np.ones(7), speed_percent)
def test_velocity_limited_motion_rejects_numeric_string_speed_percent():
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
with pytest.raises(ValueError):
motion.set_target(np.full(7, 10.0), "50")
@pytest.mark.parametrize(
"dt", ["1", "fast", True, np.bool_(False), 1 + 0j, None, np.nan,
np.inf, -1])
def test_velocity_limited_motion_rejects_invalid_dt(dt):
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
motion.set_target(np.ones(7), 50)
with pytest.raises(ValueError):
motion.step(np.zeros(7), dt)
def test_velocity_limited_motion_applies_mixed_joint_caps_independently():
limits = np.asarray([0.5, 2.0, 1.0, 3.0, 0.25, 4.0, 1.5])
motion = VelocityLimitedMotion(limits)
motion.set_target(np.full(7, 10.0), 50)
position, complete = motion.step(np.zeros(7), 1.0)
np.testing.assert_allclose(position, limits * 0.5)
assert complete is False
def test_velocity_limited_motion_clamps_each_joint_and_reports_completion():
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
target = np.asarray([1, -1, 0.1, -0.1, 0.01, -0.01, 0.001])
motion.set_target(target, 100)
position, complete = motion.step(np.zeros(7), 10.0)
np.testing.assert_allclose(position, target)
assert complete is True
assert motion.complete is True
def test_velocity_limited_motion_stop_holds_current_position():
motion = VelocityLimitedMotion(IIWA_VELOCITY_LIMITS)
current = np.arange(7, dtype=float)
motion.set_target(np.ones(7), 50)
motion.stop(current)
position, complete = motion.step(current + 10, 1.0)
np.testing.assert_allclose(position, current)
assert complete is True
def test_gui_queue_sends_picklable_plain_joint_command_data():
queue = GuiCommandQueue(get_context("spawn").Queue())
queue.send_joint([0, 1, 2, 3, 4, 5, 6], 40)
command = queue.poll()
assert isinstance(command, dict)
assert command == {"type": "joints", "values": [0., 1., 2., 3., 4., 5., 6.],
"speed_percent": 40.}
assert pickle.loads(pickle.dumps(command)) == command
queue.close()
def test_gui_queue_sends_stop_and_close_commands():
queue = GuiCommandQueue(get_context("spawn").Queue())
queue.send_stop()
queue.send_close()
assert queue.poll() == {"type": "stop"}
assert queue.poll() == {"type": "close"}
assert queue.poll() is None
queue.close()
def test_gui_command_from_fields_validates_mode_specific_field_counts():
command = command_from_fields("cartesian", [".1", ".2", ".3", "0", "1", "2"], "75")
assert command == {"type": "cartesian", "values": [.1, .2, .3, 0., 1., 2.],
"speed_percent": 75.}
with pytest.raises(ValueError):
command_from_fields("joints", ["0"] * 6, "50")
with pytest.raises(ValueError):
command_from_fields("other", ["0"] * 7, "50")
def test_gui_zero_speed_send_becomes_stop_command():
assert command_from_fields("joints", ["0"] * 7, "0") == {"type": "stop"}
@pytest.mark.parametrize("speed", ["nan", "inf", "-inf", "101", "-1"])
def test_gui_command_rejects_nonfinite_or_out_of_range_speed(speed):
with pytest.raises(ValueError):
command_from_fields("joints", ["0"] * 7, speed)
def test_gui_queue_sends_cartesian_command_data():
queue = GuiCommandQueue(get_context("spawn").Queue())
queue.send_cartesian([.1, .2, .3, 0, 1, 2], 25)
assert queue.poll() == {"type": "cartesian", "values": [.1, .2, .3, 0., 1., 2.],
"speed_percent": 25.}
queue.close()
def test_gui_queue_rejects_nonfinite_speed_and_maps_zero_to_stop():
queue = GuiCommandQueue(get_context("spawn").Queue())
with pytest.raises(ValueError):
queue.send_joint([0] * 7, float("nan"))
queue.send_cartesian([0] * 6, 0)
assert queue.poll() == {"type": "stop"}
queue.close()
def test_control_gui_can_close_before_start_and_reports_closed_status():
gui = ControlGui()
gui.close()
assert gui.status == "closed"
class FakeProcess:
def __init__(self, alive):
self.alive = iter(alive)
self.joins = []
self.terminates = 0
def is_alive(self):
return next(self.alive)
def join(self, timeout):
self.joins.append(timeout)
def terminate(self):
self.terminates += 1
def test_started_child_observer_destroys_root_after_parent_close():
class Root:
def __init__(self):
self.destroyed = False
self.scheduled = []
def destroy(self):
self.destroyed = True
def after(self, delay, callback):
self.scheduled.append((delay, callback))
root = Root()
closed = get_context("spawn").Event()
_observe_parent_close(root, closed)
assert root.destroyed is False
assert len(root.scheduled) == 1
closed.set()
root.scheduled[0][1]()
assert root.destroyed is True
def test_gui_close_joins_started_child_without_terminating_when_it_exits():
gui = ControlGui()
gui.process = FakeProcess([True, False])
gui._started = True
gui.close()
assert gui.process.joins == [2]
assert gui.process.terminates == 0
def test_gui_close_terminates_started_child_only_as_fallback():
gui = ControlGui()
gui.process = FakeProcess([True, True, False])
gui._started = True
gui.close()
assert gui.process.joins == [2, 2]
assert gui.process.terminates == 1