A small Python client for a Universal Robots e-Series arm, built on ur_rtde. It provides a command-line tool for inspection and control, and a script that generates random reachable end-effector poses and drives the arm through them.
pip install --user ur_rtde numpy scipy
Tested against a UR5e on PolyScope 5.11.1 with ur_rtde 1.6.5, numpy 2.4 and scipy 1.17.
The robot must be in Remote Control for anything that commands motion or uploads a script. That is the profile menu at the top right of the pendant. Read-only queries work in Local mode.
| file | purpose |
|---|---|
config.py |
every tunable: host, sampling region, speeds, payload, collision model |
geometry.py |
pose and rotation helpers; delegates rotation algebra to scipy |
kinematics.py |
forward kinematics for every link frame, from nominal UR5e DH |
collision.py |
self-collision and path checking; also a CLI for inspecting the model |
robot.py |
connection management, kinematics, motion, safety guards |
pose_sampler.py |
random pose generation with reachability and collision filtering |
run_cycle.py |
generate N poses and visit each once, dwelling T seconds |
ur5.py |
command-line client |
test_geometry.py |
offline checks for sampling and speed translation |
test_collision.py |
offline checks for the kinematics and the collision model |
probe6.py |
IPv6 link-local port discovery, for when IPv4 routing is broken |
python ur5.py probe # which ports are reachable
python ur5.py status # dashboard: mode, safety, program
python ur5.py state # telemetry: joints, TCP, payload
python ur5.py power-on # power on and release brakes
python ur5.py unlock # clear a protective stop
python ur5.py payload # show the configured payload
python ur5.py payload --mass 1.25 --cog 0,0,0.04
python ur5.py script prog.urscript
Motion:
python run_cycle.py --dry-run # generate and validate, no motion
python run_cycle.py # then actually move
python run_cycle.py -n 5 -T 1.5 --seed 42
python run_cycle.py --no-collision-check # only if you know why
--seed makes a run exactly reproducible, which is what you want the first
few times: dry-run a seed, look at where it wants to go, then run it for
real with the same seed.
Note that --dry-run still uploads the RTDE control script, because inverse
kinematics is a service of the controller rather than something computed
locally. It commands no motion.
The sampler asks the controller whether each pose is reachable and within the configured safety limits, checks the joint solution against the joint limits, and then checks the arm against itself and against the tool — at the destination and at every waypoint of the path leading to it. Every move is re-checked from the robot's measured position immediately before it is commanded.
That check would be worthless if the arm then took a different path. So a move
is issued as moveJ to the exact joint solution that was checked, never as
moveJ_IK to the pose: moveJ_IK re-solves the inverse kinematics on the
controller and can land on a different branch — a large, near-singular
reconfiguration the check never saw, and one aggressive enough to trip a
protective stop on its own.
Nothing here knows about your table, fixtures, cabling or the operator.
The region the tool is allowed to occupy is defined entirely by these values
in config.py:
SPHERE_RADIUS_M,MIN_RADIUS_M— a shell around the base originMIN_Z_M— floor clearance for the tool centre pointLINK_FLOOR_CLEARANCE_M— floor clearance for every arm body and tool section, which is a different and usually tighter constraintBASE_EXCLUSION_RADIUS_M,BASE_EXCLUSION_HEIGHT_M— a no-go cylinder around the pedestalORIENTATION_CONE_DEG— how far the tool may tilt from the home attitudeAXIAL_SPIN_DEG— how far it may roll about its own axis, as a total range centred on that attitude. The default of 360 allows any angleHOME_JOINTS_RAD,HOME_POSE— the configuration every cycle starts from and returns to, and the attitude the tilt cone above is measured against. Capture it from the arm's current posture withpython ur5.py state
Every cycle begins by moving to the home configuration (a no-op when the arm is already there), so the plan and the executed path share one known start regardless of where the arm was launched.
Set them for your cell before the first run, keep a hand on the pendant's
stop, and start with --dry-run.
run_cycle.py refuses to move while the payload is still at its factory
default of 0.1 kg. See below.
A sampled attitude is drawn as two independent parts, because they cost different amounts.
Tilt swings the approach axis away from the home attitude (HOME_POSE),
and drags the whole wrist through the cell to do it. That is bounded by
ORIENTATION_CONE_DEG, and bounded exactly: the perturbation is built about
an axis taken from the home attitude's own x–y plane, so the tilt angle
drawn is the angle between the two approach axes. Every pose is drawn afresh
from home rather than from the previous pose, so each one is within the cone
of home — though two poses leaning opposite ways can be up to twice the cone
apart from each other, which is why the printed plan's pose-to-pose tilt can
exceed the half-angle.
Spin rolls the tool about its own axis. When the tool is coaxial with
joint 6 — as TOOL_SECTIONS describes it, a stack of sections with no way to
express a lateral offset — every body from wrist 3 outwards is symmetric
about that axis, so spinning sweeps no new space at all. So
AXIAL_SPIN_DEG defaults to a full 360°, and joint 6 is exempt from
MAX_JOINT_STEP_RAD: capping it would only throw away spins that were
explicitly allowed, buying nothing. test_collision.py verifies the premise
rather than trusting it, by turning joint 6 through a full circle at 200
random configurations and asserting that no clearance in the model moves by
so much as a float.
The one thing this reasoning does not cover is a cable or air line being
wound up, which no geometric model can see. If you fit a tethered tool,
bring AXIAL_SPIN_DEG back down.
The spin is redrawn from the home attitude for every pose rather than added to the last one, so a long cycle cannot accumulate turns in one direction. The plan printed before a run reports tilt and spin separately for the same reason they are sampled separately.
The e-Series has no self-collision model. The controller will drive the
arm into itself without complaint; the first sign is a protective stop after
contact. So collision.py supplies one.
Each arm link is a capsule — a segment plus a radius — and the tool is a
stack of flat-capped cylinders, described in config.TOOL_SECTIONS as a
length and a diameter per section. Describe your tool there before the first
run; the shipped values are a 65 mm × 75 mm shaft carrying a 120 mm × 260 mm
drum.
The flat caps matter. A capsule's round cap sticks out a full radius past
each end, so wrapping a 260 mm-wide, 120 mm-long drum in one puts its surface
65 mm behind the flange, permanently engulfing the wrist and reporting a
collision in every configuration. Capsule-to-capsule clearance is closed
form; anything involving a cylinder is measured by sampling the capsule's
axis, and because distance to a convex set is 1-Lipschitz along a line,
subtracting half a sample spacing turns the result from an estimate into a
guaranteed lower bound. CYLINDER_SAMPLE_STEP_M trades a little
conservatism for speed.
Two things the model deliberately does not check:
- Adjacent bodies, which touch at their shared joint by construction.
- Mechanically separated pairs, listed with a claimed clearance floor in
config.MECHANICALLY_SEPARATED_PAIRS. Either both bodies are perpendicular to a shared joint axis and offset along it, so rotating that joint slides one past the other but never closer, or the joint between them turns about an axis both are symmetric around, so it changes nothing. Their clearance is the same in every configuration, so checking them per move only ever produces one verdict — and with padded radii, that verdict is sometimes a false positive.CollisionChecker.verify_separation_floors()measures every claim against random configurations rather than trusting it.
Inspect the model without a robot:
python collision.py # body table and pair exclusions at q=0
python collision.py -q 0,-90,0,-90,0,0 # ... at a configuration, in degrees
python collision.py --verify # measure every claimed clearance floor
python collision.py --validate # ... this one does need the robot
Path checking is the part that earns its keep. A moveJ is a straight line
in joint space and a curve in Cartesian space, so both endpoints being clear
says nothing about the middle. PATH_RESOLUTION_DEG sets the waypoint
spacing; at 2° the tool moves at most about 30 mm between waypoints at full
reach, comfortably finer than CAPSULE_MARGIN_M.
CAPSULE_MARGIN_M is the clearance demanded on top of the two body radii.
The 25 mm default absorbs the disagreement between this nominal-DH model and
the controller's calibrated kinematics — about 3 mm on the arm this was
written against, which python collision.py --validate measures for yours —
plus servo tracking error and cabling. Tighten it only after watching real
moves.
A robot that believes it carries 0.1 kg when it actually carries a gripper has a wrong dynamic model: its force estimates are meaningless and its joints fight a constant error term throughout every move, which is one cause of unexplained protective stops.
Configure it properly on the pendant, which writes mass and centre of gravity into the saved installation:
Installation → General → Payload → Measure
The wizard moves the arm to a few poses and solves for the load. Doing this requires Local control, so switch back to Remote Control afterwards or RTDE will refuse to drive the arm.
ur5.py payload --mass and run_cycle.py --payload-mass set the payload
over RTDE instead. That override only lasts until the controller reloads a
program, so it is for experiments, not for configuration.
The operator's budget is Cartesian: TCP_SPEED_MS and TCP_ACCEL_MS2. But
poses are commanded with moveJ (to the collision-checked joint solution,
not a re-solved pose), whose speed and acceleration arguments are rad/s and rad/s² for
the leading joint, not m/s. There is no exact conversion, because the map
from joint speed to tool speed is the Jacobian and it changes along the path.
Robot.joint_profile() therefore budgets a duration — what a trapezoidal
Cartesian profile would take to cover the straight-line distance under the
Cartesian limits — and asks the leading joint to cover its travel in that
time. Since the joint move must also accelerate and decelerate, the real
move takes longer than the budget, so the tool stays under the requested
speed rather than at it. test_geometry.py asserts that bound.
Joint speed and acceleration are additionally clamped by
JOINT_SPEED_CAP_RADS and JOINT_ACCEL_CAP_RADS2.
All three cost real debugging time and are handled in robot.py.
A failed inverse-kinematics call kills the control script. URScript's
get_inverse_kin() raises when it cannot solve a pose, and that aborts the
entire uploaded RTDE control program. ur_rtde reports it as an empty return
value and a line on stderr — not an exception. Every later call, including
isPoseWithinSafetyLimits(), then answers False, so a sampler looks like
it is rejecting poses on their merits when in fact the robot has stopped
answering. Robot.ensure_script_alive() detects this and reuploads;
run_cycle.py reports how often it happened. isPoseWithinSafetyLimits()
is non-destructive, so it is used as the gate before ever calling IK.
Forward kinematics needs the TCP offset passed explicitly.
getForwardKinematics() uses the active tool only when called with no joint
vector. Given a joint vector and an empty offset it computes against an
undefined tool and returns a pose wrong by roughly the tool length — a
plausible-looking number rather than an error. Robot.forward_kinematics()
always passes the cached getTCPOffset().
RTDEControlInterface() returns before the script is running. The
constructor finishes once the script has been sent; the controller takes a
moment longer to start it, and until it does, queries answer False or raise
— indistinguishable from the script having been aborted. The Robot.control
property waits for isProgramRunning() once, so no caller has to know the
difference.
DEFAULT_HOST in config.py is the controller's address. The host needs an
address that routes to it out the right interface; on Windows, remember that
route selection is longest-prefix-match, so a /24 on one adapter beats a
/16 on another for any address the /24 covers.
If IPv4 is not usable, probe6.py finds the controller over IPv6
link-local, which needs no address configuration:
ping -6 ff02::1%<scope-id> # solicit every node on the link
python probe6.py 'fe80::XXXX:XXXX:XXXX:XXXX%<scope-id>'
That reaches the dashboard server only. RTDE binds IPv4-only, so telemetry and motion need a working IPv4 address on the robot's subnet.
python test_geometry.py # 48 checks
python test_collision.py # 110 checks
Neither needs a robot.
test_geometry.py covers the rotation convention, isotropy of sampled
directions, volume-uniformity of the sampling shell, the tilt cone bound
(including near a half turn, where naive axis-angle arithmetic breaks, and
with the axial spin free, which must not widen it), the exactness of the
tilt/spin decomposition, the joint-6 step exemption, the local geometric
filters, and the Cartesian-to-joint speed translation.
test_collision.py covers the kinematic chain and the collision model. Both
distance primitives are checked against scipy.optimize rather than against
themselves, since a closed form with four clamped branches is exactly the
kind of thing that looks right and is wrong on one branch. Body placement is
tied back to the DH chain, so a capsule cannot drift off the arm unnoticed.
The sampled bound used for the tool is checked for conservatism against a
grid two hundred times finer. The model is shown to be exactly invariant to
joint 6, which is what licenses a free axial spin. And the path check is
proved to catch what
endpoint checking misses, on a move whose two endpoints are both clear by
more than 12 mm while its middle drives the drum 28 mm into the forearm.
Then there is the part no offline test can cover — whether the nominal DH parameters actually describe your arm:
python collision.py --validate
which compares this model's forward kinematics against the controller's
calibrated ones over 200 random configurations and reports how much of
CAPSULE_MARGIN_M the disagreement consumes. On the arm this was developed
against: about 3 mm worst case, 1.1 mm mean, leaving 22 mm of the 25 mm
margin for everything else.