Skip to content

Joint Encoder Zeroing and Mastering

Master joint encoders against mechanical references to align electronic and mechanical zero positions for accurate forward kinematics on every robot.

TofuPilotFactory CalibrationPythonTofuPilot FrameworkGitHub
Joint Encoder Zeroing and Mastering

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.

Tool centre point error caused by an angular offset on each joint alone, for 0.1, 0.02 and 0.002 degrees per joint on a 6-axis arm with 1.3 m of link length: a base joint offset moves the tool by millimetres, a pin-fixture mastering keeps it below 50 micrometres.

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

Mastering result of the mock arm: encoder offset written per joint in arcseconds with J4 at -291 arcseconds as the largest, motor-vs-joint lost motion per joint against the 0.5 arcminute cycloidal and 1.0 arcminute harmonic limits, and the remaster spread per joint under the 18 arcsecond limit.

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:

  1. Setup: the operator scans their badge and picks the fixture kit; the safety circuit is checked.
  2. For each joint: brake off, jog onto the witness pin, read the joint encoder and the motor encoder, write the offset, brake on.
  3. Repeat the pin approach on every joint and compare: the procedure's repeatability.
  4. Optional touch-probe finishing pass (disabled by default).
  5. Read every offset back, confirm the multi-turn counters, attach the mastering file, mark the unit as mastered.
  6. 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

procedure.yaml
ui.json
phases
interlocks.py
master_joints.py
repeatability.py
touch_probe.py
verify_written.py
brakes_engaged.py
plugs
robot.py
utils
joints.py
README.md
pyproject.toml

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:

procedure.yaml
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:

  1. Operator on the run: operated_by: {} at the root declares the field; the badge text input carries bind: run.operated_by, so the scan becomes the run's operator with no Python, and later phases read it as run.operated_by.
  2. Ordered setup: setup phases with satisfied dependencies run concurrently like main phases. interlocks depends on identify_operator so the badge is on record before the safety check logs it, and both must pass before any brake releases.
  3. One aggregation per axis: the mastering series carries the offset and the delta per joint; max_abs_deg validates the offsets against the ±0.5° installation limit, and worst_vs_limit is the largest delta divided by that joint's own gearbox limit, so one validator covers two gearbox types.
  4. Disabled phase: touch_probe ships with enabled: false and is skipped silently; a cell with a touch probe flips it on without touching code.
  5. JSON tables: offsets and remaster_spread are 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:

phases/master_joints.py
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:

utils/joints.py
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.0

Repeatability

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:

phases/repeatability.py
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_DEG

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

phases/verify_written.py
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:

plugs/robot.py
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 True

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

Run your first test in minutes