Skip to content

Repository files navigation

UR_controller

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.

Requirements

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.

Layout

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

Usage

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.

Safety

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 origin
  • MIN_Z_M — floor clearance for the tool centre point
  • LINK_FLOOR_CLEARANCE_M — floor clearance for every arm body and tool section, which is a different and usually tighter constraint
  • BASE_EXCLUSION_RADIUS_M, BASE_EXCLUSION_HEIGHT_M — a no-go cylinder around the pedestal
  • ORIENTATION_CONE_DEG — how far the tool may tilt from the home attitude
  • AXIAL_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 angle
  • HOME_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 with python 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.

Orientation: tilt and spin are not the same thing

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.

Collision checking

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.

Payload

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.

Speed, and why it is expressed twice

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.

Three ur_rtde behaviours worth knowing

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.

Networking

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.

Tests

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages