
Introduction
Encoder Mastering Overview
Every robot joint reports its angle through an encoder, and every kinematic computation downstream assumes the reported angle matches the true mechanical angle. Mastering establishes this agreement: it aligns the electronic zero (encoder counts) with the mechanical zero (a physical reference: witness pin, alignment block, hardstop). Without correct mastering the transform chain accumulates angular offsets across joints, and on a 6-axis arm an offset on a base joint moves the tool by the whole downstream arm length.

Why the fixture matters: an unmastered 0.1° on J1 is over 2 mm at the tool, a witness mark by eye leaves half a millimetre, a pin fixture at 0.002° keeps every joint's contribution below 50 µm.
Encoder types found in production robots: optical absolute (Renishaw RESOLUTE, Heidenhain RCN), magnetic absolute off-axis rings (RLS AksIM, the joint-side encoder of most cobots), inductive (Celera IncOder) for dust, oil and EMI immunity, and resolver plus Hall on the motor side in harsh or low-cost designs. Modern joints carry a dual-encoder architecture: a motor-side encoder feeding the current loop and a joint-side encoder downstream of the harmonic or cycloidal reducer feeding the position loop. Comparing the two at rest is how this template reads gearbox lost motion without any extra instrument.
Mastering Purpose
Per-joint mastering produces:
- Encoder zero offset per joint, written to the controller's mastering data
- Motor-vs-joint encoder delta at rest: the gearbox lost-motion signature, checked against each gearbox's own limit
- Repeatability of the mastering pose: jog away, back onto the pin, read again
- Read-back of what was written, the multi-turn counter state, and the mastering file attached to the run

One run on the mock arm: six offsets of a few arcminutes (J4's encoder sits slightly rotated on its shaft, still far inside the ±0.5° limit), lost motion under each gearbox's limit, and a remaster spread of a few arcseconds that says the fixture, not the operator, defines the zero.
Per-joint mastering catches: an encoder installed rotated on its shaft, a gearbox cassette swapped from another joint without remastering, a corrupted mastering file, a multi-turn counter that wrapped while the battery was out during shipping.
Beyond the metrology, this template demonstrates five framework mechanics: the operator's badge bound to the run's operator field, a radio bound to a string measurement, an ordered setup stage so nothing moves before the safety circuit is confirmed, a multi-dimensional measurement with one custom aggregation per axis, and a disabled optional phase for cells with a touch probe.
Equipment & Setup
To implement joint mastering on a production line, the following are required:
- A mechanical reference fixture: witness pins, a machined alignment block, or a hardstop with shim, one geometry per joint, on a granite base
- Optional touch probe or dial indicator for a finishing pass on high-accuracy arms
- The Device Under Test (DUT): a fully assembled arm with all joints connected, in service mode
- A TofuPilot Framework procedure to drive the per-joint sequence, capture both encoders, and validate
- The TofuPilot Dashboard to log per-unit offsets and trend assembly quality
Hardware Components
Reference Fixture
The production-grade fixture is a machined alignment block with witness pins matching the joint geometry: ±5 µm on pin diameter, ±10 µm on hole concentricity, on a granite base plate square to gravity. Each joint has its own geometry, so the station carries a fixture kit; the kit in use is recorded on every run, because pin wear shows up as a slow drift of the remaster spread and has to be traceable to the kit.
Controller Service Interface
Mastering uses the controller's own service routine: UR "Joint Zeroing", KUKA "Mastering", ABB "Calibration", FANUC "Quick Master". The procedure drives it through the vendor API (UR dashboard and RTDE, KUKA Sunrise, ABB RobotWare, FANUC KAREL) and reads both encoders through the same interface. Cobots that offer a fixture-free gravity-based zeroing still benefit from the pin for the repeatability check.
Custom Firmware
None. The controller runs production firmware in service mode; the procedure only needs brake release, jog, encoder read and offset write, which every vendor exposes.
Test Procedure
Overview
With the arm in service mode and the fixture kit on the bench, the procedure runs:
- Setup: the operator scans their badge and picks the fixture kit; the safety circuit is checked.
- For each joint: brake off, jog onto the witness pin, read the joint encoder and the motor encoder, write the offset, brake on.
- Repeat the pin approach on every joint and compare: the procedure's repeatability.
- Optional touch-probe finishing pass (disabled by default).
- Read every offset back, confirm the multi-turn counters, attach the mastering file, mark the unit as mastered.
- Teardown: every brake engaged before the fixture comes off.
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 controller plug is a mock six-joint arm with a real offset per joint and a lost motion per gearbox. Run it with tofupilot run . --no-tui --no-kiosk --json --ui-values ui.json, or drop --ui-values to scan a badge and pick the kit yourself. 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:
name: Joint Encoder Zeroing and Masteringversion: 0.1.0description: Masters the six joint encoders of an assembled arm against the witness-pin fixture, checks the motor-vs-joint encoder delta per gearbox, measures the procedure's own repeatability, and attaches the mastering file.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" metadata: mastered: description: "Set by the station once the offsets are written and read back"operated_by: {}plugs: - name: Robot Controller description: Service interface of the controller under test (mock six-joint arm) python: plugs.robot:RobotController key: robotsetup: - name: Identify Operator key: identify_operator ui: components: - key: badge type: text_input label: "Scan your badge" required: true bind: run.operated_by - key: fixture_kit type: radio label: "Fixture kit on the bench" required: true bind: measurements.fixture_kit options: - label: "Kit A (serial FIX-A-012)" value: "kit_a" - label: "Kit B (serial FIX-B-007)" value: "kit_b" measurements: - name: Fixture Kit key: fixture_kit validators: - {operator: in, expected_value: ["kit_a", "kit_b"]} - name: Interlocks key: interlocks python: phases.interlocks depends_on: [identify_operator] measurements: - name: E-Stop Healthy key: estop_healthy validators: - {operator: "==", expected_value: true}main: - name: Master Joints key: master_joints python: phases.master_joints ui: components: - key: mastering_progress type: progress label: "Mastering J1 to J6" default_value: 0 max: 100 measurements: - name: Mastering key: mastering title: Encoder offset and motor-vs-joint delta per joint x_axis: {legend: Joint} y_axis: - legend: Encoder offset key: offset unit: arcsec aggregations: - type: max_abs_deg unit: deg validators: - {operator: "<=", expected_value: 0.5} - legend: Motor-vs-joint delta key: delta unit: arcmin aggregations: - type: worst_vs_limit validators: - {operator: "<=", expected_value: 1.0} - name: Offsets key: offsets description: Encoder offset per joint in arcseconds, the fingerprint of this arm's assembly. - name: Repeatability key: repeatability python: phases.repeatability depends_on: [master_joints] measurements: - name: Remaster Spread key: remaster_spread - name: Remaster Worst key: remaster_worst unit: deg description: Largest difference between two successive masterings of the same joint. validators: - {operator: "<=", expected_value: 0.005} - name: Touch Probe Finish key: touch_probe python: phases.touch_probe enabled: false depends_on: [repeatability] measurements: - name: Probe Residual key: probe_residual unit: deg validators: - {operator: "<=", expected_value: 0.001} - name: Verify Written key: verify_written python: phases.verify_written depends_on: [repeatability] measurements: - name: Offsets Written key: offsets_written validators: - {operator: "==", expected_value: true} - name: Multi-Turn Valid key: multiturn_valid validators: - {operator: "==", expected_value: true}teardown: - name: Brakes Engaged key: brakes_engaged python: phases.brakes_engaged measurements: - name: All Brakes Engaged key: all_brakes_engaged validators: - {operator: "==", expected_value: true}Framework features to notice:
- Operator on the run:
operated_by: {}at the root declares the field; the badge text input carriesbind: run.operated_by, so the scan becomes the run's operator with no Python, and later phases read it asrun.operated_by. - Ordered setup: setup phases with satisfied dependencies run concurrently like main phases.
interlocksdepends onidentify_operatorso the badge is on record before the safety check logs it, and both must pass before any brake releases. - One aggregation per axis: the
masteringseries carries the offset and the delta per joint;max_abs_degvalidates the offsets against the ±0.5° installation limit, andworst_vs_limitis the largest delta divided by that joint's own gearbox limit, so one validator covers two gearbox types. - Disabled phase:
touch_probeships withenabled: falseand is skipped silently; a cell with a touch probe flips it on without touching code. - JSON tables:
offsetsandremaster_spreadare recorded as per-joint objects, so the exact numbers are on the run without six measurements each.
Master Joints
Per joint: brake off, jog until the witness pin engages, read the joint-side encoder (the offset to write) and the motor-side encoder divided by the gear ratio (the lost motion shows in the difference), write, brake on. The limit for the delta comes from the joint table, per gearbox:
from utils.joints import ARCSEC_PER_DEG, JOINTSdef master_joints(measurements, robot, ui, log): """Per joint: brake off, jog to the witness pin, read both encoders, write the offset, brake on.""" offsets_arcsec, deltas_arcmin, worst_ratio = [], [], 0.0 for i, joint in enumerate(JOINTS): name = joint["name"] robot.release_brake(name) robot.jog_to_reference(name) joint_deg = robot.raw_joint_encoder_deg(name) motor_deg = robot.motor_encoder_deg(name) robot.write_offset(name, joint_deg) robot.engage_brake(name) offset_arcsec = joint_deg * ARCSEC_PER_DEG delta_arcmin = abs(motor_deg - joint_deg) * 60.0 offsets_arcsec.append(offset_arcsec) deltas_arcmin.append(delta_arcmin) worst_ratio = max(worst_ratio, delta_arcmin / joint["delta_limit_arcmin"]) log.info(f"{name}: offset {offset_arcsec:+.0f}\" ({joint_deg:+.4f} deg), motor-vs-joint {delta_arcmin:.2f}' (limit {joint['delta_limit_arcmin']}')") ui.mastering_progress = int(100 * (i + 1) / len(JOINTS)) measurements.mastering.x_axis = list(range(1, len(JOINTS) + 1)) measurements.mastering.y_axis.offset = offsets_arcsec measurements.mastering.y_axis.delta = deltas_arcmin measurements.mastering.y_axis.offset.aggregations.max_abs_deg = max(abs(o) for o in offsets_arcsec) / ARCSEC_PER_DEG measurements.mastering.y_axis.delta.aggregations.worst_vs_limit = worst_ratio measurements.offsets = {j["name"]: round(o, 1) for j, o in zip(JOINTS, offsets_arcsec)}The joint table pairs each joint with its gearbox and the lost-motion limit that goes with it:
JOINTS = [ {"name": "J1", "gearbox": "cycloidal", "ratio": 121, "delta_limit_arcmin": 0.5}, {"name": "J2", "gearbox": "cycloidal", "ratio": 121, "delta_limit_arcmin": 0.5}, {"name": "J3", "gearbox": "harmonic", "ratio": 101, "delta_limit_arcmin": 1.0}, {"name": "J4", "gearbox": "harmonic", "ratio": 101, "delta_limit_arcmin": 1.0}, {"name": "J5", "gearbox": "harmonic", "ratio": 101, "delta_limit_arcmin": 1.0}, {"name": "J6", "gearbox": "harmonic", "ratio": 101, "delta_limit_arcmin": 1.0},]ARCSEC_PER_DEG = 3600.0Repeatability
The joints are driven off the pin and back, and the encoder is read again. The difference with the offset just written is the repeatability of the mastering itself; a pin fixture holds it to a few arcseconds, a witness mark by eye to a few arcminutes. The first offsets arrive through previous-results injection:
from utils.joints import ARCSEC_PER_DEG, JOINTSdef repeatability(measurements, robot, master_joints, log): """Jog away, come back onto the pin, read again: the spread is the procedure's own repeatability.""" first = master_joints.offsets diffs = [] for joint in JOINTS: name = joint["name"] robot.release_brake(name) robot.jog_to_reference(name) second = robot.raw_joint_encoder_deg(name) * ARCSEC_PER_DEG robot.engage_brake(name) diffs.append(abs(second - float(first[name]))) worst = max(diffs) log.info(f"Remaster spread per joint: {[round(d, 1) for d in diffs]} arcsec, worst {worst:.1f}\"") measurements.remaster_spread = {j["name"]: round(d, 1) for j, d in zip(JOINTS, diffs)} measurements.remaster_worst = worst / ARCSEC_PER_DEGVerify Written
Every offset is read back from the controller, the multi-turn counters are confirmed, the mastering file is attached to the run, and the unit is marked as mastered in its metadata so downstream stations (kinematic calibration, ISO 9283) can check for it:
import jsonfrom utils.joints import JOINTSdef verify_written(measurements, robot, unit, attach, master_joints, log): """Read every offset back from the controller and attach the mastering file to the run.""" readback = {j["name"]: robot.read_offset(j["name"]) for j in JOINTS} measurements.offsets_written = all(v is not None for v in readback.values()) measurements.multiturn_valid = all(robot.multiturn_valid(j["name"]) for j in JOINTS) mastering_file = {"serial_number": unit.serial_number, "offsets_deg": readback, "offsets_arcsec": master_joints.offsets} attach.data(json.dumps(mastering_file, indent=2).encode(), f"{unit.serial_number}_mastering.json") unit.metadata["mastered"] = True log.info("Offsets read back and mastering file attached")Mock Plug
The controller mock carries one true offset per joint (J4's encoder is seated 0.08° rotated), a lost motion per gearbox drawn around a quarter of an arcminute, and a sub-arcsecond pin clearance. Its method surface is what a wrapper around the vendor service API has to provide:
import numpy as npfrom utils.joints import ARCSEC_PER_DEG, JOINTSclass RobotController: def __init__(self): self._rng = np.random.default_rng(6) # true encoder-zero to mechanical-zero offset per joint, degrees self._true_offset = {j["name"]: self._rng.normal(0.0, 0.05) for j in JOINTS} self._true_offset["J4"] = -0.08 # encoder seated slightly rotated on J4 self._lost_motion = {j["name"]: abs(self._rng.normal(0.25, 0.08)) for j in JOINTS} # arcmin self._brakes = {j["name"]: True for j in JOINTS} self._offset_written = {} print("Controller service mode, all brakes engaged") def raw_joint_encoder_deg(self, joint): """Joint-side encoder reading at the mechanical reference: the offset plus pin clearance.""" return self._true_offset[joint] + self._rng.normal(0.0, 0.0008) def motor_encoder_deg(self, joint): """Motor-side reading divided by the gear ratio, at the same reference.""" ratio = next(j["ratio"] for j in JOINTS if j["name"] == joint) lost = self._lost_motion[joint] / 60.0 * self._rng.choice([-1.0, 1.0]) return self._true_offset[joint] + lost + self._rng.normal(0.0, 0.0005 / ratio) def write_offset(self, joint, offset_deg): self._offset_written[joint] = float(offset_deg) return TrueOn a real cell, plugs/robot.py maps to the vendor service API; the phases, measurements, and limits stay exactly the same.