Skip to content

ISO 9283 Repeatability and Accuracy Test

Run the ISO 9283 pose accuracy and repeatability test cycle on every robot at end-of-line and report per-unit performance against datasheet spec.

TofuPilotEnd-of-LinePythonTofuPilot FrameworkGitHub
ISO 9283 Repeatability and Accuracy Test

Introduction

ISO 9283 Overview

ISO 9283:1998 "Manipulating industrial robots — Performance criteria and related test methods" is the standard every robot datasheet cites for pose accuracy and repeatability. Originally written for type and acceptance testing, it has become the contractual reference for end-of-line robot QA: the number on the datasheet is the number each shipped unit has to reach.

The 400 mm ISO 9283 test cube in the robot base frame with P1 at the centre and P2 to P5 on the diagonal plane, each 10 percent of the diagonal in from a corner, and the cycle path P1 to P5.

The test cube of this template: 400 mm, placed where the robot works most, P1 at the centre and P2 to P5 on the diagonal plane, each pulled 10 % of the diagonal in from its corner as the standard asks.

For two robots to be comparable under ISO 9283, six conditions must match: cube size and location, payload, velocity, path, cycle count, environment. The standard specifies 30 cycles per pose at 100 % rated payload and rated velocity, with additional runs at 50 % and 10 % velocity where the datasheet claims them. Pose accuracy AP is the distance between the commanded position and the barycentre of the attained ones; pose repeatability RP is the mean radial distance of the attained positions to that barycentre plus three standard deviations.

Test Purpose

The ISO 9283 procedure produces a per-robot performance fingerprint:

  • Pose accuracy AP and pose repeatability RP at each of the five poses, 30 cycles each
  • Multi-directional pose accuracy variation vAP at P1, four approach directions
  • Distance accuracy AD between two poses on the plane, expressed per metre
  • Test conditions: payload, velocity, ambient temperature and humidity, recorded on the run
  • An acceptance record attached to the run, the datasheet-style page that travels with the robot

Scatter of the 30 attained P1 positions in the XY plane around the commanded point: the barycentre offset of 37 µm is the pose accuracy against a 150 µm limit, the dashed circle of 22 µm radius around the barycentre is the pose repeatability against a 30 µm limit.

P1 on the mock robot: the cloud of 30 attained positions sits 37 µm from the commanded point (accuracy) and spreads 22 µm around its own centre (repeatability). Accuracy is what kinematic calibration fixes; repeatability is what the gearboxes, encoders and brakes deliver.

Datasheet values for 5 kg-class cobots and small industrial arms sit between ±0.02 and ±0.03 mm repeatability. Every shipped robot has to hit its datasheet or go back for kinematic recalibration, gearbox inspection, or brake re-tuning.

Beyond the metrology, this template demonstrates four framework mechanics: operator inputs bound to measurements for the test conditions with pre-baked answers for unattended runs, an automatic retry on error for the tracker lock, five multi-dimensional measurements with custom aggregations carrying AP and RP per pose, and a sequential DAG that keeps the phases sharing one robot from running at the same time.

Equipment & Setup

To implement ISO 9283 at end-of-line, the following are required:

  • A laser tracker for 3-DOF (or 6-DOF with a probe) end-effector measurement
  • A retroreflector (SMR) in a magnetic nest at the tool flange, or a 6-DOF probe
  • A reference payload matching the robot's rated payload, bolted to the flange
  • The Device Under Test (DUT): a kinematically calibrated robot on its production pedestal
  • A TofuPilot Framework procedure to drive the pose sequence, capture the tracker, and compute the ISO 9283 metrics
  • The TofuPilot Dashboard to log per-unit results and trend production performance

Robot acceptance cell: six-axis robot holding the reflector at its wrist, the laser tracker on its tripod with the beam on the target, the test cube outlined in the workspace, and the production test computer showing the pose scatter.

The cell: robot on its pedestal, tracker on its tripod with the beam on the reflector at the wrist, the test cube in the working volume, and the test computer driving the cycles.

Hardware Components

Laser Tracker

The production reference is a Hexagon Leica Absolute Tracker AT960-class instrument (tens of micrometres of volumetric uncertainty, up to 1 kHz with the 6-DOF T-Mac probe) or a FARO Vantage. A 1.5 inch SMR in a magnetic nest at the flange is the simplest target; the tracker averages the last 500 ms of each dwell to reject the settling transient. For linear-axis diagnostics a Renishaw XL-80 interferometer and for circularity the Renishaw QC20 ballbar remain the specialised tools, not part of the ISO 9283 cycle itself.

Reference Payload

Measurements at 100 % rated payload need a calibrated reference mass matching the robot's specification: for a 5 kg cobot, a 5 kg machined steel block with a CMM-verified centre of gravity, bolted directly to the flange with no plastic adapters that would add compliance.

Environment

ISO 9283 asks for a stable cell temperature (20 °C ± 2 K is the usual acceptance window) and for the tracker to be warmed up and levelled. The procedure records the tracker's own environmental readings on the run so two results can be compared under the same conditions.

Custom Firmware

The robot controller runs production firmware. The test station commands the trajectory through the vendor's motion API (URScript over RTDE, RAPID, KRL, KAREL, the vendor Python SDK); no special firmware is required.

Test Procedure

Overview

After mounting the SMR and the reference payload and levelling the tracker, the procedure runs:

  1. The operator confirms the payload and picks the velocity in the UI.
  2. The tracker locks on the reflector (retried automatically if the beam is not on the target yet), the environment is recorded, the robot gets its test conditions.
  3. 30 cycles P1 → P5, one tracker reading per dwell; AP and RP per pose.
  4. P1 approached from four directions, three cycles each; vAP.
  5. P2 and P4 30 times; distance accuracy per metre.
  6. The acceptance record is built from the three previous phases and attached.

Why TofuPilot Framework?

TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:

  • Automatic Python environment management (via uv)
  • Operator UI (no frontend code needed)
  • Measurement validation and live charts
  • Process isolation between phases and equipment plugs

Project Structure

procedure.yaml
ui.json
phases
setup_cell.py
pose_cycles.py
multi_direction.py
distance.py
acceptance_report.py
plugs
robot.py
tracker.py
utils
cube.py
iso9283.py
README.md
pyproject.toml

The robot mock is a calibrated 5 kg cobot with small systematic offsets per pose and a 0.02 mm-class settle; the tracker mock fails its first lock the way a real one does when the beam is not on the reflector yet. Run it with tofupilot run . --no-tui --no-kiosk --json --ui-values ui.json. You can find the full source on GitHub.

The Procedure File

This is the exact file the template ships, verified end to end with the CLI. The five pose measurements share one shape, so P2 to P5 are elided here and shown in full in the repository:

procedure.yaml
name: ISO 9283 Repeatability and Accuracy Testversion: 0.1.0description: Runs the ISO 9283 pose accuracy and repeatability cycle on a calibrated robot with a laser tracker, plus multi-directional accuracy and distance accuracy, and attaches the acceptance record.unit:  auto_identify: true  serial_number:    description: "Robot serial from the base plate"    placeholder: "RB-0000000"    pattern: "^RB-\\d{7}$"    default_value: "RB-2026041"  part_number:    default_value: "COBOT-5KG-R2"plugs:  - name: Robot Controller    description: Motion API of the robot under test (mock 5 kg cobot)    python: plugs.robot:RobotController    key: robot  - name: Laser Tracker    description: Tracker on the SMR at the tool flange (mock AT960-class)    python: plugs.tracker:LaserTracker    key: trackermain:  - name: Configure Test    key: configure_test    ui:      components:        - key: payload_kg          type: number_input          label: "Test payload (kg)"          description: "Reference mass bolted to the flange, 100 % of rated payload per ISO 9283"          default_value: 5          bind: measurements.payload_kg        - key: velocity_pct          type: select          label: "Velocity"          bind: measurements.velocity_pct          options:            - label: "100 % rated"              value: "100"            - label: "50 %"              value: "50"            - label: "10 %"              value: "10"          default_value: "100"    measurements:      - name: Payload        key: payload_kg        unit: kg        validators:          - {operator: "==", expected_value: 5}      - name: Velocity        key: velocity_pct        unit: "%"        validators:          - {operator: in, expected_value: ["100", "50", "10"]}  - name: Setup Cell    key: setup_cell    python: phases.setup_cell    depends_on: [configure_test]    retry:      limit: 2      delay: 1s    then:      error: retry    measurements:      - name: Tracker Locked        key: tracker_locked        validators:          - {operator: "==", expected_value: true}      - name: Ambient Temperature        key: ambient_temperature        unit: °C        description: ISO 9283 asks for 20 °C ± 2 K in the cell.        validators:          - {operator: ">=", expected_value: 18.0}          - {operator: "<=", expected_value: 22.0}  - name: Pose Cycles    key: pose_cycles    python: phases.pose_cycles    depends_on: [setup_cell]    timeout: 30m    ui:      components:        - key: cycle_progress          type: progress          label: "30 cycles, P1 to P5"          default_value: 0          max: 100    measurements:      - name: Pose P1        key: pose_p1        title: P1 attained positions over 30 cycles        x_axis: {legend: Cycle}        y_axis:          - {legend: dx, key: dx, unit: mm}          - {legend: dy, key: dy, unit: mm}          - {legend: dz, key: dz, unit: mm}          - legend: Radial to barycentre            key: radial            unit: mm            aggregations:              - type: ap_mm                unit: mm                validators:                  - {operator: "<=", expected_value: 0.15}              - type: rp_mm                unit: mm                validators:                  - {operator: "<=", expected_value: 0.03}      # Pose P2 to Pose P5: same shape as Pose P1      - name: RP Max        key: rp_max        unit: mm        description: Worst pose repeatability over the five poses; the datasheet number.        validators:          - {operator: "<=", expected_value: 0.03}      - name: AP Max        key: ap_max        unit: mm        validators:          - {operator: "<=", expected_value: 0.15}  - name: Multi-Directional Accuracy    key: multi_direction    python: phases.multi_direction    depends_on: [pose_cycles]    measurements:      - name: vAP at P1        key: vap_p1        unit: mm        description: Spread of the P1 barycentres over four approach directions.        validators:          - {operator: "<=", expected_value: 0.15}  - name: Distance Accuracy    key: distance    python: phases.distance    depends_on: [multi_direction]    measurements:      - name: AD        key: ad_mm        unit: mm      - name: AD per metre        key: ad_mm_per_m        unit: mm/m        validators:          - {operator: ">=", expected_value: -0.4}          - {operator: "<=", expected_value: 0.4}  - name: Acceptance Report    key: acceptance_report    python: phases.acceptance_report    depends_on: [distance]    measurements:      - name: Report Attached        key: report_attached        validators:          - {operator: "==", expected_value: true}

Framework features to notice:

  1. Operator inputs bound to measurements: the payload and the velocity are entered once, land as validated measurements on the run, and ui.json pre-bakes them for unattended runs. Because select values are strings, the velocity is a string measurement validated with in.
  2. Retry on error: then: {error: retry} with retry: {limit: 2, delay: 1s} on the tracker lock. A tracker that has not found the reflector yet raises; the framework re-runs the phase a second later instead of failing the robot. Both attempts stay on the run.
  3. Custom aggregations per pose: each pose is a multi-dimensional measurement with the three axis deviations and the radial distance to the barycentre; ap_mm and rp_mm are computed in Python on that series and validated in YAML, so the chart and the limits live together.
  4. One robot, one phase at a time: pose_cycles, multi_direction and distance are chained with depends_on even though none needs the other's result, because they share the same robot and tracker. Phases with satisfied dependencies run concurrently, and two phases moving one robot would interleave their commands.
  5. Run metadata: the tracker's environment reading, the payload and the velocity are written to run.metadata, so runs can be filtered by test conditions on the dashboard.

Setup Cell

The tracker lock is the step that fails on a real bench when the beam is not on the reflector; the retry rule handles it. The phase reads the operator's answers through previous-results injection from configure_test, hands the test conditions to the robot, and records the environment on the run:

phases/setup_cell.py
def setup_cell(measurements, tracker, robot, run, configure_test, log):    payload = float(configure_test.payload_kg)    velocity = int(configure_test.velocity_pct)    robot.set_test_conditions(payload, velocity)    measurements.tracker_locked = tracker.lock()    env = tracker.environment()    run.metadata["ambient_temp_c"] = env["temperature_c"]    run.metadata["humidity_pct"] = env["humidity_pct"]    run.metadata["payload_kg"] = payload    run.metadata["velocity_pct"] = velocity    measurements.ambient_temperature = env["temperature_c"]    log.info(f"Tracker locked, {env['temperature_c']} C, payload {payload} kg, velocity {velocity} %")

In the JSON event stream the retry is explicit: attempt 1 ends ERROR with beam not locked on the reflector, attempt 2 ends PASS one second later.

Pose Cycles

Thirty cycles through the five poses, one tracker reading at the end of each dwell. The per-pose series are filled through getattr on the measurements object, and the two ISO 9283 numbers are set as aggregations on the radial axis:

phases/pose_cycles.py
import numpy as npfrom utils.cube import CYCLES, posesfrom utils.iso9283 import pose_accuracy, pose_repeatabilitydef pose_cycles(measurements, robot, tracker, ui, log):    """30 cycles P1 -> P5, tracker reading at the end of each dwell. AP and RP per pose."""    targets = poses()    attained = {name: [] for name in targets}    for cycle in range(CYCLES):        for name, xyz in targets.items():            robot.move_to(name, xyz.tolist())            attained[name].append(tracker.measure(robot.attained()))        ui.cycle_progress = int(100 * (cycle + 1) / CYCLES)    for name, xyz in targets.items():        ap, bary = pose_accuracy(xyz, attained[name])        rp, radial = pose_repeatability(attained[name])        log.info(f"{name}: AP {ap:.3f} mm, RP {rp:.3f} mm")        series = getattr(measurements, f"pose_{name.lower()}")        series.x_axis = list(range(1, CYCLES + 1))        series.y_axis.dx = [float(p[0] - xyz[0]) for p in attained[name]]        series.y_axis.dy = [float(p[1] - xyz[1]) for p in attained[name]]        series.y_axis.dz = [float(p[2] - xyz[2]) for p in attained[name]]        series.y_axis.radial = radial.tolist()        series.y_axis.radial.aggregations.ap_mm = ap        series.y_axis.radial.aggregations.rp_mm = rp    measurements.rp_max = max(pose_repeatability(a)[0] for a in attained.values())    measurements.ap_max = max(pose_accuracy(targets[n], a)[0] for n, a in attained.items())

The ISO 9283 arithmetic lives in a small utility module:

utils/iso9283.py
import numpy as npdef pose_accuracy(commanded, attained):    """AP: distance between the commanded position and the barycentre of the attained ones."""    bary = np.mean(np.asarray(attained), axis=0)    return float(np.linalg.norm(bary - np.asarray(commanded))), barydef pose_repeatability(attained):    """RP = l_mean + 3 * s_l, with l the radial distance of each attained position to the barycentre."""    a = np.asarray(attained)    bary = a.mean(axis=0)    l = np.linalg.norm(a - bary, axis=1)    s_l = np.sqrt(np.sum((l - l.mean()) ** 2) / (len(l) - 1))    return float(l.mean() + 3.0 * s_l), l

Pose accuracy and pose repeatability per pose P1 to P5 of the mock robot: AP between 27 and 59 µm under the 150 µm limit, RP between 17.6 and 23.3 µm under the 30 µm limit, over 30 cycles at 100 percent payload and velocity.

The five poses of the mock robot: repeatability between 18 and 23 µm against the 30 µm datasheet limit, accuracy between 27 and 59 µm against 150 µm. The worst pose is the number that goes on the acceptance record.

Multi-Directional Accuracy and Distance

vAP repeats P1 from four approach directions, three cycles each, and takes the largest distance between the four barycentres. Distance accuracy commands P2 and P4, a diagonal of the plane, thirty times and compares the attained distance with the commanded one, expressed per metre so the number is comparable across cube sizes:

phases/distance.py
from utils.cube import CYCLES, posesfrom utils.iso9283 import distance_accuracydef distance(measurements, robot, tracker, log):    """Distance accuracy between P2 and P4 (a cube diagonal on the plane), 30 pairs."""    targets = poses()    a, b = [], []    for _ in range(CYCLES):        robot.move_to("P2", targets["P2"].tolist())        a.append(tracker.measure(robot.attained()))        robot.move_to("P4", targets["P4"].tolist())        b.append(tracker.measure(robot.attained()))    ad, d_cmd = distance_accuracy(targets["P2"], targets["P4"], a, b)    log.info(f"AD over {d_cmd:.1f} mm: {ad:+.3f} mm ({1000 * ad / d_cmd:+.3f} mm/m)")    measurements.ad_mm = ad    measurements.ad_mm_per_m = 1000.0 * ad / d_cmd

Acceptance Report

The last phase reads the three result phases through previous-results injection and attaches the acceptance record to the run, named after the serial so it is findable from the unit page:

phases/acceptance_report.py
import jsondef acceptance_report(measurements, unit, run, pose_cycles, multi_direction, distance, attach, log):    """Datasheet-style acceptance record attached to the run as JSON."""    report = {        "serial_number": unit.serial_number,        "part_number": unit.part_number,        "conditions": {k: run.metadata.get(k) for k in ("payload_kg", "velocity_pct", "ambient_temp_c", "humidity_pct")},        "ap_max_mm": float(pose_cycles.ap_max),        "rp_max_mm": float(pose_cycles.rp_max),        "vap_p1_mm": float(multi_direction.vap_p1),        "ad_mm_per_m": float(distance.ad_mm_per_m),        "standard": "ISO 9283:1998, 30 cycles, 5 poses on the diagonal plane of a 400 mm cube",    }    attach.data(json.dumps(report, indent=2).encode(), f"{unit.serial_number}_iso9283.json")    measurements.report_attached = True    log.info("Acceptance record attached")

Mock Plugs

The robot mock keeps one systematic offset per pose (what AP sees), adds a 4 µm-class settle on every move (what RP sees), and biases each approach direction slightly (what vAP sees). The tracker mock adds its own 4 µm noise and fails its first lock. Plug return values cross a process boundary as JSON, so the mock returns plain lists, never NumPy arrays:

plugs/robot.py
import numpy as npclass RobotController:    def __init__(self):        self._rng = np.random.default_rng(9283)        self._offsets = {}        self._pose = np.zeros(3)        self._payload_kg = 0.0        self._velocity_pct = 100        print("Robot controller connected, servo on")    def set_test_conditions(self, payload_kg, velocity_pct):        self._payload_kg = float(payload_kg)        self._velocity_pct = int(velocity_pct)    def move_to(self, name, xyz, direction=0):        """Move and settle; the attained position is what the tracker will see."""        if name not in self._offsets:            self._offsets[name] = self._rng.normal(0.0, 0.045, 3)        bias = np.array([0.02, -0.015, 0.01]) * (direction - 1.5) / 1.5        settle = self._rng.normal(0.0, 0.004, 3)        self._pose = np.asarray(xyz) + self._offsets[name] + bias + settle        return True    def dwell_s(self):        return 2.0    def attained(self):        return self._pose.tolist()

On a real cell, plugs/robot.py maps to the vendor motion API and plugs/tracker.py to the tracker SDK; the phases, measurements, and limits stay exactly the same.

Run your first test in minutes