Add a controller#

Prerequisites: the application dependencies, basic Python/NumPy, and controller priorities. Outcome: a positional controller tested with in-memory inputs.

1. Implement the error contract#

The example attracts a current point to a fixed target. It returns a positional error in metres and claims all three translation directions. The GTA execution client projects this packet with the other controller errors.

docs/examples/point_attractor.py — positional controller example#
"""Educational position controller. Computes errors; never commands hardware."""

import numpy as np

from controllers.base_controller import BaseController


class PointAttractor(BaseController):
    """Track a sampled 3D target using a live point callback, in metres."""

    def __init__(self, current_point, target_point):
        super().__init__()
        if not callable(current_point):
            raise TypeError("current_point must be callable")
        self.type = "positional"
        self.current_point = current_point
        self.target = self._point(target_point).copy()

    @staticmethod
    def _point(value):
        point = np.asarray(value, dtype=float)
        if point.shape != (3,) or not np.isfinite(point).all():
            raise ValueError("Expected three finite coordinates")
        return point

    def compute_error(self, elapsed_time=0.0):
        current = self._point(self.current_point())
        return {
            "type": self.type,
            "error": self.target - current,
            # This controller claims all three translation directions.
            "axes": np.eye(3),
        }

current_point is callable because the robot point changes during execution. target_point is sampled at construction. Reject malformed or non-finite inputs before producing a command packet. elapsed_time is normalized subtask progress from 0 to 1, despite its name; this controller does not use it.

2. Test priority projection#

Run from the repository root in the application environment:

import numpy as np
from docs.examples.point_attractor import PointAttractor
from gta.error_projection import project_errors

controller = PointAttractor(lambda: np.zeros(3), [0.1, 0.2, 0.3])
packet = controller.compute_error(0.5)
position, rotation, force, gripper = project_errors([packet])
np.testing.assert_allclose(position, [0.1, 0.2, 0.3])
np.testing.assert_allclose(rotation, np.eye(3))
assert force is None and gripper is None

first = {"type": "positional", "error": [0.04, 0, 0], "axis": [1, 0, 0]}
position, *_ = project_errors([first, packet])
np.testing.assert_allclose(position, [0.04, 0.2, 0.3])

The earlier X constraint wins. The attractor contributes only Y and Z in the second case. Reversing the order makes the full-position attractor claim every translation direction first. Projection consumes packet order, not the priority field itself. The generator preserves the recipe’s list order; it does not sort that list by its numeric priority labels.

3. Register the controller#

To make the example in docs/examples/ available to the planner:

  1. Put the implementation in controllers/point_attractor.py.

  2. Export PointAttractor from controllers/__init__.py.

  3. Add it to the generated from controllers import (...) block in gta/script_generator.py.

  4. Add "PointAttractor": [0] to apply_dynamic_parameters() so the current point becomes a lambda in generated code. Otherwise it is evaluated once.

  5. Teach agents/prompts/skill_generator.py its exact signature, axis ownership, ordering, and appropriate use. Update agents/prompts/parameter_setter.py if it introduces physical parameters, units, or constraints. Update task-planning guidance only if its capabilities actually change. Existing checkpoint prompts are preserved snapshots.

  6. Test translation, priority interactions, invalid inputs, and the generated script’s syntax with the offline tests.

A recipe could then use PointAttractor(g_eef_TCP, g_Ocube_Ptop_Lcenter). The existing translator already recognizes those keypoints. New argument syntax or helper functions need their own translator and import support.

4. Compare with the supplied controllers#

PosAlign constrains one axis instead of claiming a full position:

class PosAlign(BaseController):
    """
    Move tool keypoint g1 to g2 along a1, applying offset o1 along the a1 axis.
    
    a_along_keypoints(g1,g2) is a common axis choice for moving the points directly to each other.
    """
        
    def __init__(self, g1, g2, a1, o1):
        """
        Initialize PosAlign controller.
        
        Args:
            g1: Tool keypoint position function (lambda function)
            g2: Target keypoint position (numpy array)
            a1: Axis along which to align (numpy array)
            o1: Offset value along the axis (float)
        """
        self.type = "positional"

        self.g1 = g1
        self.g2 = np.asarray(g2)
        self.a1 = np.asarray(a1) / np.linalg.norm(np.asarray(a1))  # Normalize
        self.o1 = o1

        self.target = self.g2 + self.a1 * self.o1
    
        print(f"[PosAlign] Initialized: moving from {self.g1()} to {self.g2} along axis {self.a1} with offset {self.o1}")

    def compute_error(self, elapsed_time=0.0):
        """Execute the position alignment controller."""
        error = self.target - self.g1()
        # Project the error on a1 axis to ensure movement is along the desired direction
        error = np.dot(error, self.a1) * self.a1

        return {'error': error, 'type': self.type, 'axis': self.a1}

    def __repr__(self):
        return f"PosAlign(g1={self.g1()}, g2={self.g2}, a1={self.a1}, o1={self.o1}, target={self.target})"

Distances are metres; AxisAlign constructor offsets are degrees, while its error packet is a rotation vector in radians. Force controllers emit force packets with magnitudes in newtons.

Verify: run the offline documentation example tests. Then inspect the controller catalog and runtime evaluation rules. For unresolved names or stale generated output, see execution troubleshooting.

Download the offline example