
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 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

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

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:
- The operator confirms the payload and picks the velocity in the UI.
- 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.
- 30 cycles P1 → P5, one tracker reading per dwell; AP and RP per pose.
- P1 approached from four directions, three cycles each; vAP.
- P2 and P4 30 times; distance accuracy per metre.
- 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
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:
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:
- Operator inputs bound to measurements: the payload and the velocity are entered once, land as validated measurements on the run, and
ui.jsonpre-bakes them for unattended runs. Because select values are strings, the velocity is a string measurement validated within. - Retry on error:
then: {error: retry}withretry: {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. - Custom aggregations per pose: each pose is a multi-dimensional measurement with the three axis deviations and the radial distance to the barycentre;
ap_mmandrp_mmare computed in Python on that series and validated in YAML, so the chart and the limits live together. - One robot, one phase at a time:
pose_cycles,multi_directionanddistanceare chained withdepends_oneven 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. - 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:
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:
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:
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
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:
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_cmdAcceptance 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:
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:
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.