Добавлено управление роботом
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user