GLaDOS Vol 10 · Reaching Out
ch 94 / 99
Chapter 94

Pointing the Arm at a Target

Ninety-three chapters of angles somebody typed

Every angle this arm has ever been given was picked by a person. Chapter 69's demo reached to 45, 90 and 90 because those three numbers looked about right on the bench. The pose library in its exercises is a dictionary somebody filled in by hand, one row at a time, checking each result by eye. That is a fine way to work while a human is the one choosing.

It stops working the moment the thing choosing is her. Her camera finds a mug on the desk and what comes back is a place: so many centimetres out, so many centimetres up. Servos do not take places. They take angles. Between those two sits one piece of arithmetic, and the arm has been missing it for seven volumes.

You could measure your way around it. Drive the arm to a hundred poses, write down where the gripper ended up each time, and look the answer up later. That table is wrong the first time you reprint a link at a different length, it has nothing to say about any point between two of its rows, and it cannot tell you that a point is out of reach until a servo is already straining against a printed stop. The information is already in the geometry of the two links. It only has to be solved for.

Here is the arm as geometry. There is no joint that rotates the base, so everything happens in one vertical plane. Put the origin at the shoulder pivot, let x run out away from the base and y run up, both in centimetres. The upper arm is 10.0 cm from the shoulder pivot to the elbow pivot. The forearm is 7.0 cm from the elbow pivot to the point between the jaws, with the wrist at its home angle of 90 degrees, where the gripper lines up with the forearm. Two links and a target make three sides, so: the two links and the straight line from the shoulder to the target form a triangle, solving that triangle gives the joint angles, and every set of angles it hands back is a proposal that still has to pass this arm's joint bands and clear the table.

◆ Note — where the lengths came from, and what needs to be plugged in

The two link lengths were read off a steel rule, pivot centre to pivot centre, on the arm built in Volume 7. Nothing here is measured more finely than that: a pivot centre is a hole you are eyeballing, so 10.0 and 7.0 are honest to about a millimetre and no better. The tabletop sits 6.0 cm below the shoulder pivot on this bench, which is the height of the printed base column. Measure your own three numbers and put them in the constants at the top of the module, because every result below is only as true as they are. The whole chapter is arithmetic and runs on a laptop with nothing plugged in; the arm is only needed for the last exercise.

The triangle inside the arm, and the gates a target passes through Left: the shoulder pivot on a base column 6 centimetres above the tabletop, with a 10 centimetre upper arm and a 7 centimetre forearm reaching a target 13 centimetres away. Two paths reach that same target: a gold one with the elbow below the straight line to the target, which this arm can hold, and a red dashed one with the elbow above the line, which needs an elbow angle of minus 81.8 degrees and is outside the joint's band. Right: five gates stacked in order, distance between 3 and 17 centimetres, the triangle solved twice, shoulder inside 15 to 165 degrees, elbow inside 0 to 150 degrees, and nothing below minus 5.5 centimetres, ending in a pose the arm can hold. THE TRIANGLE INSIDE THE ARM FIVE GATES · POINT TO POSE tabletop, y = -6.0 cm base column, 6.0 cm 13.0 cm shoulder 10.0 7.0 target (12, 5) elbow +81.8, held elbow -81.8, refused both paths end on the same point and only one is inside the bands distance is 3.0 to 17.0 cm 17.89 cm is past full stretch the triangle, solved twice one bend and its mirror image shoulder inside 15 to 165 170.4 fails, and so does 234.8 elbow inside 0 to 150 every negative bend stops here nothing below y = -5.5 cm an elbow pivot at -7.48 fails a pose the arm can hold
Figure 94.1 — Three of the five gates say nothing about trigonometry. They are facts about printed brackets and a desk, and a target can clear the geometry and fail every one of them.

Walk the easy direction first

▣ Build · stage 1 — angles in, a point out
# labs/kinematics.py
"""Two-link geometry for the arm: joint angles to a point, and back again."""
import math
from dataclasses import dataclass

L1_CM = 10.0                # shoulder pivot to elbow pivot
L2_CM = 7.0                 # elbow pivot to the point between the jaws
SHOULDER_ZERO_DEG = 90.0    # the shoulder angle that points the upper arm straight out

MAX_REACH_CM = L1_CM + L2_CM
MIN_REACH_CM = abs(L1_CM - L2_CM)


@dataclass(frozen=True)
class Pose:
    """Two joint angles, in the units chapter 69's config bounds."""
    shoulder_deg: float
    elbow_deg: float


def forward_kinematics(pose: Pose, l1: float = L1_CM,
                       l2: float = L2_CM) -> tuple[float, float]:
    """Where the grip point lands, in centimetres from the shoulder pivot."""
    t1 = math.radians(pose.shoulder_deg - SHOULDER_ZERO_DEG)
    t2 = math.radians(pose.elbow_deg)
    return (l1 * math.cos(t1) + l2 * math.cos(t1 + t2),
            l1 * math.sin(t1) + l2 * math.sin(t1 + t2))


def elbow_point(pose: Pose, l1: float = L1_CM) -> tuple[float, float]:
    """Where the elbow pivot sits, which is not where the grip point sits."""
    t1 = math.radians(pose.shoulder_deg - SHOULDER_ZERO_DEG)
    return l1 * math.cos(t1), l1 * math.sin(t1)


if __name__ == "__main__":
    print(f"upper arm {L1_CM:.1f} cm, forearm {L2_CM:.1f} cm, "
          f"reach {MIN_REACH_CM:.1f} to {MAX_REACH_CM:.1f} cm")
    for name, pose in (("home", Pose(90.0, 30.0)), ("straight out", Pose(90.0, 0.0)),
                       ("folded", Pose(90.0, 150.0)), ("aimed high", Pose(140.0, 60.0)),
                       ("aimed low", Pose(40.0, 90.0))):
        x, y = forward_kinematics(pose)
        ex, ey = elbow_point(pose)
        print(f"{name:<13} shoulder {pose.shoulder_deg:5.1f}  elbow {pose.elbow_deg:5.1f}"
              f"   elbow at ({ex:6.2f},{ey:6.2f})   grip at ({x:6.2f},{y:6.2f})"
              f"   {math.hypot(x, y):5.2f} cm out")
$ uv run python -m labs.kinematics
upper arm 10.0 cm, forearm 7.0 cm, reach 3.0 to 17.0 cm
home          shoulder  90.0  elbow  30.0   elbow at ( 10.00,  0.00)   grip at ( 16.06,  3.50)   16.44 cm out
straight out  shoulder  90.0  elbow   0.0   elbow at ( 10.00,  0.00)   grip at ( 17.00,  0.00)   17.00 cm out
folded        shoulder  90.0  elbow 150.0   elbow at ( 10.00,  0.00)   grip at (  3.94,  3.50)    5.27 cm out
aimed high    shoulder 140.0  elbow  60.0   elbow at (  6.43,  7.66)   grip at (  4.03, 14.24)   14.80 cm out
aimed low     shoulder  40.0  elbow  90.0   elbow at (  6.43, -7.66)   grip at ( 11.79, -3.16)   12.21 cm out

This direction is a chain of additions and no unknowns at all. Walk 10 cm along whatever direction the shoulder is pointing, turn by the elbow's bend, walk 7 cm more, and you are at the jaws. The only subtlety is the constant: a shoulder reading of 90 puts the upper arm straight out along x, so the geometry works in shoulder_deg - 90 while the servo works in the 15 to 165 band chapter 69 measured. That offset appears in exactly two functions here, and later in exactly one more.

Two rows are the useful ones. The home pose puts the jaws 16.44 cm from the shoulder, almost fully stretched out, and folding the elbow to its 150 limit pulls them back to 5.27 cm; that span is the working depth of this arm. The last row is the one to remember: an elbow pivot at y = -7.66 is 1.66 cm underneath a tabletop that sits at -6.0, and the grip point in the same row is comfortably above it. Position the fingertip correctly and you can still put the elbow through the desk.

The triangle inside the arm

∑ Math Interlude — one triangle, three known sides

Take the target at (12, 5). Its distance from the shoulder is the hypotenuse of a right triangle with legs 12 and 5: the square root of 144 plus 25, which is the square root of 169, which is exactly 13. Now look at the triangle whose corners are the shoulder, the elbow and the jaws. All three of its sides are known numbers: 10, 7 and 13. Nothing about it is a mystery any more, because a triangle with three fixed side lengths has only one possible set of corner angles.

The law of cosines is the tool that reads those angles off. For a triangle with sides a, b and c, where C is the angle in the corner opposite side c, it says c² = a² + b² - 2ab·cos(C). With numbers: 169 equals 100 plus 49 minus 140 times the cosine, so 169 = 149 - 140·cos(C), so cos(C) is (149 - 169) divided by 140, which is -0.1429. The angle whose cosine is -0.1429 is 98.21 degrees, and that is the interior angle of the triangle at the elbow corner.

The joint does not read interior angles. It reads how far the forearm has bent away from straight, which is 180 minus 98.21, or 81.79 degrees. Flipping that subtraction is the same as flipping the sign of the cosine, so the version in the code puts the distance first: cos(bend) = (d² - l1² - l2²) / (2·l1·l2), giving +0.1429 and 81.79 degrees in one step. That number is a direct readout of how open the arm is. At d = 17 the cosine is +1 and the bend is 0, dead straight. At d = 3 the cosine is -1 and the bend is 180, folded back on itself. Outside 3 to 17 the cosine leaves the interval from -1 to +1, and no triangle with those three sides exists.

dstraight-line distance from the shoulder pivot to the target, in cm
l1, l2the two link lengths: 10.0 cm upper arm, 7.0 cm forearm
bendhow far the forearm has turned away from straight; the elbow joint's own reading
coscosine: for an angle from 0 to 180 degrees it slides from +1 down to -1, once each
acosthe reverse: hand it a number from -1 to +1, get the angle back
atan2(y, x)the direction of the point (x, y) from the origin, correct in all four quarters
▣ Build · stage 2 — the elbow, then where to aim the shoulder
# labs/ik_walkthrough.py
import math

from labs.kinematics import L1_CM, L2_CM, Pose, forward_kinematics

x, y = 12.0, 5.0
dist = math.hypot(x, y)
cos_elbow = (dist ** 2 - L1_CM ** 2 - L2_CM ** 2) / (2 * L1_CM * L2_CM)
bend = math.acos(cos_elbow)

k1 = L1_CM + L2_CM * math.cos(bend)      # where the jaws land with the shoulder at zero
k2 = L2_CM * math.sin(bend)
aim = math.atan2(y, x) - math.atan2(k2, k1)

print(f"target ({x:.1f}, {y:.1f}) is {dist:.2f} cm from the shoulder")
print(f"cos(bend) = ({dist ** 2:.0f} - {L1_CM ** 2:.0f} - {L2_CM ** 2:.0f}) "
      f"/ {2 * L1_CM * L2_CM:.0f} = {cos_elbow:+.4f}   bend = {math.degrees(bend):.2f} deg")
print(f"shoulder at zero would put the grip at ({k1:.2f}, {k2:.2f}), "
      f"{math.hypot(k1, k2):.2f} cm out at {math.degrees(math.atan2(k2, k1)):.2f} deg")
print(f"the target sits at {math.degrees(math.atan2(y, x)):.2f} deg, "
      f"so the shoulder turns {math.degrees(aim):+.2f} deg from its zero")
print(f"shoulder = {math.degrees(aim) + 90.0:.2f} deg,  elbow = {math.degrees(bend):.2f} deg")
gx, gy = forward_kinematics(Pose(math.degrees(aim) + 90.0, math.degrees(bend)))
print(f"forward kinematics on that pose: ({gx:.2f}, {gy:.2f})")
$ uv run python -m labs.ik_walkthrough
target (12.0, 5.0) is 13.00 cm from the shoulder
cos(bend) = (169 - 100 - 49) / 140 = +0.1429   bend = 81.79 deg
shoulder at zero would put the grip at (11.00, 6.93), 13.00 cm out at 32.20 deg
the target sits at 22.62 deg, so the shoulder turns -9.58 deg from its zero
shoulder = 80.42 deg,  elbow = 81.79 deg
forward kinematics on that pose: (12.00, 5.00)

The middle two lines are the whole trick for the shoulder, and they are less clever than they look. Once the elbow's bend is fixed the arm is a rigid bent stick, so bolt it to the shoulder at the shoulder's zero and ask where the jaws land: at k1 = l1 + l2·cos(bend) across and k2 = l2·sin(bend) up, which for this target is (11.00, 6.93). That point is 13.00 cm from the shoulder, the same 13.00 the target is, because the stick did not change length. It is merely pointing 32.20 degrees off, when the target lies at 22.62. Rotate the whole stick by the difference, -9.58 degrees, and the jaws swing onto the target. Add the 90 that the shoulder calls zero and the servo wants 80.42.

math.atan2 and not math.atan, both times. Plain atan(y / x) divides by zero on any target directly above the shoulder and gives the same answer for a point in front and a point behind, since dividing two negatives loses both signs. atan2 is handed the two numbers separately and keeps them, so it can tell 22.62 degrees from 157.38.

▣ Build · stage 3 — both answers, and the targets that have none
# labs/kinematics.py — added below elbow_point


def solve(x: float, y: float, l1: float = L1_CM,
          l2: float = L2_CM) -> list[Pose]:
    """Every pose that puts the grip point on (x, y). Empty if no triangle closes."""
    dist = math.hypot(x, y)
    if dist > l1 + l2 or dist < abs(l1 - l2):
        return []
    cos_elbow = (dist ** 2 - l1 ** 2 - l2 ** 2) / (2 * l1 * l2)
    cos_elbow = max(-1.0, min(1.0, cos_elbow))       # pin float drift into acos's domain
    elbow = math.acos(cos_elbow)
    poses = []
    for bend in (elbow, -elbow):
        k1 = l1 + l2 * math.cos(bend)
        k2 = l2 * math.sin(bend)
        aim = math.atan2(y, x) - math.atan2(k2, k1)
        poses.append(Pose(math.degrees(aim) + SHOULDER_ZERO_DEG, math.degrees(bend)))
    return poses
# labs/ik_branches.py
import math

from labs.kinematics import MAX_REACH_CM, MIN_REACH_CM, forward_kinematics, solve

for tx, ty in ((12.0, 5.0), (11.0, -2.0), (16.0, 8.0), (2.0, 0.0)):
    dist = math.hypot(tx, ty)
    poses = solve(tx, ty)
    if not poses:
        print(f"({tx:5.1f},{ty:5.1f}) {dist:6.2f} cm   no triangle closes "
              f"({MIN_REACH_CM:.1f} to {MAX_REACH_CM:.1f} cm)")
        continue
    for pose in poses:
        gx, gy = forward_kinematics(pose)
        print(f"({tx:5.1f},{ty:5.1f}) {dist:6.2f} cm   shoulder {pose.shoulder_deg:7.2f}"
              f"   elbow {pose.elbow_deg:7.2f}   back through FK ({gx:6.2f},{gy:6.2f})")
$ uv run python -m labs.ik_branches
( 12.0,  5.0)  13.00 cm   shoulder   80.42   elbow   81.79   back through FK ( 12.00,  5.00)
( 12.0,  5.0)  13.00 cm   shoulder  144.82   elbow  -81.79   back through FK ( 12.00,  5.00)
( 11.0, -2.0)  11.18 cm   shoulder   41.61   elbow   99.87   back through FK ( 11.00, -2.00)
( 11.0, -2.0)  11.18 cm   shoulder  117.78   elbow  -99.87   back through FK ( 11.00, -2.00)
( 16.0,  8.0)  17.89 cm   no triangle closes (3.0 to 17.0 cm)
(  2.0,  0.0)   2.00 cm   no triangle closes (3.0 to 17.0 cm)

Two rows per target, and the fourth column proves both of them. Negating the bend mirrors the elbow across the straight line from the shoulder to the target, and because the mirror image of a triangle is still that triangle, the jaws land in the same place. The sign flips inside k2, which flips the sign of the correction, which swings the shoulder up instead of down. One target, two honest answers, and nothing in the trigonometry that prefers either.

The two rejected targets fail before any trigonometry runs, and that ordering is the point of the guard. A target 17.89 cm out gives a cosine of +1.2214, and 2.00 cm in gives -1.0357. Both are outside the interval math.acos accepts, so without the guard the run ends in ValueError: math domain error from somewhere deep inside a solver, for a target whose problem was obvious from its distance alone. The clamp on the next line is a second, quieter defence: a target sitting exactly on the 17.0 cm boundary can round to 1.0000000002 on the way through the arithmetic, and pinning it back to 1.0 costs nothing and keeps a legal target from raising.

Solved is not the same as reachable

▣ Build · stage 4 — checking a pose against printed parts and a desk
# labs/arm_reach.py
"""A solved pose is a proposal. This module decides whether the arm can hold it."""
import math

from labs.arm_config import JOINTS
from labs.kinematics import (MAX_REACH_CM, MIN_REACH_CM, Pose, elbow_point,
                             forward_kinematics, solve)

TABLE_Y_CM = -6.0        # the tabletop, measured down from the shoulder pivot
CLEARANCE_CM = 0.5       # how close to it anything is allowed to come
FLOOR_Y_CM = TABLE_Y_CM + CLEARANCE_CM


def blocked_because(pose: Pose) -> str | None:
    """The first reason this arm cannot hold this pose, or None if it can."""
    for name, degrees in (("shoulder", pose.shoulder_deg), ("elbow", pose.elbow_deg)):
        cfg = JOINTS[name]
        if not cfg.min_deg <= degrees <= cfg.max_deg:
            return (f"{name} {degrees:.1f} is outside its "
                    f"{cfg.min_deg:.0f}-{cfg.max_deg:.0f} band")
    for label, (_, height) in (("elbow pivot", elbow_point(pose)),
                               ("grip point", forward_kinematics(pose))):
        if height < FLOOR_Y_CM:
            return f"{label} at y={height:.2f} is below the keep-out line at y={FLOOR_Y_CM:.2f}"
    return None


def plan_reach(x: float, y: float) -> tuple[Pose | None, list[str]]:
    """A pose the arm can hold that puts the grip point on (x, y), and why not."""
    poses = solve(x, y)
    if not poses:
        dist = math.hypot(x, y)
        edge = "past full stretch" if dist > MAX_REACH_CM else "inside the fold radius"
        return None, [f"{dist:.2f} cm from the shoulder is {edge} "
                      f"({MIN_REACH_CM:.1f} to {MAX_REACH_CM:.1f} cm)"]
    refused = []
    for pose in poses:
        reason = blocked_because(pose)
        if reason is None:
            return pose, refused
        refused.append(reason)
    return None, refused

Nothing in this module knows any trigonometry and nothing in labs/kinematics.py knows this arm exists. That split is deliberate. The solver answers a question about two lengths and a point, which would have the same answer on any planar arm anywhere; blocked_because answers a question about printed brackets, a servo's travel and a particular desk. The band numbers are read straight out of the JOINTS config from chapter 69, so recalibrating a joint changes what is reachable without a line of this file being touched.

▣ Build · stage 5 — six targets, and every reason one can fail
# labs/arm_reach.py — continued

if __name__ == "__main__":
    print(f"tabletop at y={TABLE_Y_CM:.1f} cm, nothing allowed below y={FLOOR_Y_CM:.1f} cm")
    for tx, ty in ((12.0, 5.0), (3.0, 12.0), (11.0, -2.0),
                   (-5.0, 12.0), (16.0, 8.0), (2.0, 0.0)):
        pose, refused = plan_reach(tx, ty)
        head = f"({tx:6.1f},{ty:6.1f})  {math.hypot(tx, ty):5.2f} cm"
        if pose is None:
            print(f"{head}  ->  no pose this arm can hold")
            for reason in refused:
                print(f"{'':>21}   rejected: {reason}")
        else:
            gx, gy = forward_kinematics(pose)
            print(f"{head}  ->  shoulder {pose.shoulder_deg:6.2f}  "
                  f"elbow {pose.elbow_deg:6.2f}   FK ({gx:6.2f},{gy:6.2f})")
$ uv run python -m labs.arm_reach
tabletop at y=-6.0 cm, nothing allowed below y=-5.5 cm
(  12.0,   5.0)  13.00 cm  ->  shoulder  80.42  elbow  81.79   FK ( 12.00,  5.00)
(   3.0,  12.0)  12.37 cm  ->  shoulder 131.51  elbow  88.36   FK (  3.00, 12.00)
(  11.0,  -2.0)  11.18 cm  ->  no pose this arm can hold
                        rejected: elbow pivot at y=-7.48 is below the keep-out line at y=-5.50
                        rejected: elbow -99.9 is outside its 0-150 band
(  -5.0,  12.0)  13.00 cm  ->  no pose this arm can hold
                        rejected: shoulder 170.4 is outside its 15-165 band
                        rejected: shoulder 234.8 is outside its 15-165 band
(  16.0,   8.0)  17.89 cm  ->  no pose this arm can hold
                        rejected: 17.89 cm from the shoulder is past full stretch (3.0 to 17.0 cm)
(   2.0,   0.0)   2.00 cm  ->  no pose this arm can hold
                        rejected: 2.00 cm from the shoulder is inside the fold radius (3.0 to 17.0 cm)

Read the third target slowly, because it is the one that changes how you think about a solved pose. Both answers put the jaws on (11.0, -2.0) and both are perfectly good trigonometry. The first bends the elbow 99.87 degrees, well inside the 0 to 150 band, and drops the elbow pivot to y = -7.48, roughly a centimetre and a half inside the desk. The second holds the elbow pivot up at y = 4.66 and clears everything, and asks the elbow for -99.87 degrees. The elbow servo's band starts at 0, so the joint that would keep the arm off the table is the joint that cannot bend that way at all. A point 11 cm out and 2 cm down is not reachable on this arm, and no amount of better solving will change that.

The fourth target fails differently: a point up and behind the shoulder needs 170.4 degrees, and the printed bracket takes everything past 165. Its mirror answer wants 234.8, which is not even a servo angle. Between them the six rows cover every way a point can be refused: too far, too close, past a joint stop, and into the furniture.

Why this works: three sides fix a triangle, and only two ways to draw it

A triangle with three known side lengths is rigid. Cut three sticks and pin them together at the ends and there is exactly one figure you can make, up to sliding and turning it and flipping it over. That rigidity is what makes a closed-form answer possible at all, and it is why the law of cosines needs no iteration or starting guess: the angles were determined the moment the three lengths were.

The two answers per target are that last freedom, the flip. Reflecting the elbow across the line from the shoulder to the target leaves all three side lengths alone, so it leaves the target alone, and it is the only other way to pin those sticks together. An arm with three links in a plane has infinitely many answers instead of two, and that is the point where closed-form solutions stop and numerical solvers start. Two links, two answers, both of them exact.

The reachability guard and the trigonometry are one fact stated twice. dist > l1 + l2 is algebraically the same condition as cos(bend) > 1, and dist < abs(l1 - l2) is the same as cos(bend) < -1. Checking the distance first is not a second check, it is the same check moved to where it can produce a sentence about reach instead of an exception about a domain error.

The general lesson survives past arms. A solver's answer is a statement about the model you gave it, and the model is always smaller than the machine. Two lengths and a target have nothing in them about a bracket, a desk, a cable that will not stretch that far, or a servo that grinds past 165. Keeping the solver ignorant of all of that is what lets it be tested on a laptop and reused on the next arm; keeping a separate layer that knows every one of those facts is what keeps a mathematically perfect answer from driving a printed part into a table.

⚠ Worked failure — the clamp that made a wrong answer look right

The solver works, the round-trip proves it, and the band check in stage 4 starts to look like belt and braces. Chapter 69 already clamps: _set_joint pins every request into the joint's band before it writes a channel, and it has been doing that since the arm was built. So the shortcut writes itself. Solve, hand the two angles to reach, and let the existing guard catch anything silly:

# labs/arm_point.py
from labs.arm_control import RoboticArm
from labs.arm_driver import ChannelDriver
from labs.kinematics import solve

TARGET = (-5.0, 12.0)

arm = RoboticArm(ChannelDriver())
arm.home()
pose = solve(*TARGET)[0]
print(f"solved   shoulder {pose.shoulder_deg:.2f}  elbow {pose.elbow_deg:.2f}")
arm.reach(pose.shoulder_deg, pose.elbow_deg, 90.0)
print(f"arm says {arm.pose()}")
arm.grab()
$ uv run python -m labs.arm_point   # measured on the bench — yours will vary
solved   shoulder 170.42  elbow 81.79
arm says shoulder= 165.0  elbow=  81.8  wrist=  90.0  gripper=   0.0

No exception, no warning, and the jaws shut on air. Two numbers in that output disagree and it takes a moment to see it: 170.42 was asked for and 165.0 is recorded. The clamp did its job, refused to grind the bracket, and silently substituted a different pose. So run forward kinematics on the pose that was actually driven rather than the one that was solved:

# labs/arm_point_check.py
import math

from labs.arm_config import JOINTS
from labs.kinematics import Pose, forward_kinematics, solve

TARGET = (-5.0, 12.0)


def as_driven(pose: Pose) -> Pose:
    """What _set_joint actually writes, once each band has had its say."""
    s, e = JOINTS["shoulder"], JOINTS["elbow"]
    return Pose(max(s.min_deg, min(s.max_deg, pose.shoulder_deg)),
                max(e.min_deg, min(e.max_deg, pose.elbow_deg)))


solved = solve(*TARGET)[0]
driven = as_driven(solved)
sx, sy = forward_kinematics(solved)
dx, dy = forward_kinematics(driven)
print(f"target        ({TARGET[0]:6.2f},{TARGET[1]:6.2f})")
print(f"solved pose   shoulder {solved.shoulder_deg:6.2f}  elbow {solved.elbow_deg:6.2f}"
      f"   FK ({sx:6.2f},{sy:6.2f})")
print(f"driven pose   shoulder {driven.shoulder_deg:6.2f}  elbow {driven.elbow_deg:6.2f}"
      f"   FK ({dx:6.2f},{dy:6.2f})")
print(f"the gripper closes {math.hypot(dx - TARGET[0], dy - TARGET[1]):.2f} cm from the target")
$ uv run python -m labs.arm_point_check
target        ( -5.00, 12.00)
solved pose   shoulder 170.42  elbow  81.79   FK ( -5.00, 12.00)
driven pose   shoulder 165.00  elbow  81.79   FK ( -3.85, 12.42)
the gripper closes 1.23 cm from the target

There it is. Five and a half degrees of shoulder, taken quietly by the clamp, moves the jaws 1.23 cm at the far end of a 17 cm arm, which is more than the width of the handle you were reaching for. The round-trip check never saw it, because the round trip verified the solved pose and the servo received a different one. A clamp is the right last line of defence for a joint and the wrong answer to a target: it is built to protect the hardware, so its job is to change your request, and a layer whose job is to change your request cannot also be the layer that tells you your request was impossible. plan_reach refuses first, while the two numbers still mean what they say.

Checkpoint, and a target nothing has produced yet

✓ Checkpoint — what you can now do
  • Given a target and two link lengths, I can work out the distance, the cosine of the elbow bend, and the bend itself, using numbers rather than a formula I am trusting.
  • I can explain what k1 and k2 are physically, and why subtracting their angle from the target's angle aims the shoulder.
  • I can state the two reasons no triangle closes and show that each is the same statement as a cosine leaving the interval from -1 to +1.
  • I know why every target has two answers, why negating the bend produces the second, and why this arm can only ever hold one of them.
  • Handed a pose that survives both joint bands, I still check where the elbow pivot is before I let the arm move.
  • I can say why a clamp protects the hardware and hides the error, and where the refusal has to happen instead.
⚡ Exercises — try first, then reveal
Exercise 1 — print the reachable set. Sweep a grid of targets one centimetre apart, mark the ones plan_reach accepts, and look at the region the arm can actually work in.

Nothing new is needed, only a loop and one character per point:

from labs.arm_reach import plan_reach

for y in range(14, -8, -2):
    row = "".join("#" if plan_reach(float(x), float(y))[0] else "."
                  for x in range(-6, 19, 1))
    print(f"y={y:+3d} |{row}")
print("      x runs -6 cm on the left to +18 cm on the right, one column per cm")
$ uv run python -m labs.reach_map
y=+14 |....############.........
y=+12 |..#################......
y=+10 |..##################.....
y= +8 |..####################...
y= +6 |...###################...
y= +4 |..........#############..
y= +2 |...........############..
y= +0 |...................#####.
y= -2 |.....................##..
y= -4 |......................#..
y= -6 |.........................
      x runs -6 cm on the left to +18 cm on the right, one column per cm

The bite out of the left side between y=+6 and y=+4 is not the fold radius. A point at (0, 4) is 4.00 cm out, inside the 3 to 17 band, and its cosine of -0.95 asks the elbow for 161.8 degrees, past the 150 stop. Close-in points need a tighter fold than the printed link allows. Low and far out, the region thins to a few columns because the elbow pivot keeps dropping into the desk.

Exercise 2 — walk a straight line. Sample points evenly along a line between two reachable targets, solve each one, and print how much the shoulder moves per step.
from labs.arm_reach import plan_reach

START, END, STEPS = (15.0, 0.0), (3.0, 12.0), 6
prev = None
for step in range(STEPS + 1):
    f = step / STEPS
    x = START[0] + (END[0] - START[0]) * f
    y = START[1] + (END[1] - START[1]) * f
    pose, _ = plan_reach(x, y)
    delta = "" if prev is None else f"   d shoulder {pose.shoulder_deg - prev:+6.2f}"
    print(f"({x:5.1f},{y:5.1f})  shoulder {pose.shoulder_deg:6.2f}  "
          f"elbow {pose.elbow_deg:6.2f}{delta}")
    prev = pose.shoulder_deg
$ uv run python -m labs.line_walk
( 15.0,  0.0)  shoulder  66.93  elbow  57.12
( 13.0,  2.0)  shoulder  67.12  elbow  80.13   d shoulder  +0.20
( 11.0,  4.0)  shoulder  73.41  elbow  94.92   d shoulder  +6.29
(  9.0,  6.0)  shoulder  84.64  elbow 103.21   d shoulder +11.23
(  7.0,  8.0)  shoulder  99.29  elbow 104.90   d shoulder +14.65
(  5.0, 10.0)  shoulder 115.35  elbow  99.87   d shoulder +16.06
(  3.0, 12.0)  shoulder 131.51  elbow  88.36   d shoulder +16.16

The seven points are evenly spaced in centimetres and wildly uneven in degrees: the shoulder barely moves for the first step and then takes eighty times as much on the last. Chapter 69's reach interpolates between two poses in angle, so it draws a curve through space, not a line. Solving at samples along the line and driving each sample in turn is how a straight edge gets traced, and it also shows why the same motion is slow at one end and quick at the other.

Exercise 3 (stretch) — give the elbow the other half of its travel. Suppose the elbow bracket were reprinted to allow -150 to 150. Re-check the target that failed, and decide whether you would actually build it.
import labs.arm_config as arm_config
from labs.arm_config import JointConfig
from labs.arm_reach import blocked_because
from labs.kinematics import elbow_point, solve

arm_config.JOINTS["elbow"] = JointConfig(channel=1, min_deg=-150.0,
                                         max_deg=150.0, home_deg=30.0)

for pose in solve(11.0, -2.0):
    _, elbow_y = elbow_point(pose)
    reason = blocked_because(pose)
    print(f"shoulder {pose.shoulder_deg:6.2f}  elbow {pose.elbow_deg:7.2f}"
          f"   elbow pivot y={elbow_y:6.2f}   {reason or 'holdable'}")
$ uv run python -m labs.wide_elbow
shoulder  41.61  elbow   99.87   elbow pivot y= -7.48   elbow pivot at y=-7.48 is below the keep-out line at y=-5.50
shoulder 117.78  elbow  -99.87   elbow pivot y=  4.66   holdable

The target that was impossible becomes routine, and the reachable map from exercise 1 grows a whole lower-left region. The catch is that a servo's command range covers 180 degrees of shaft and no more, so a band of -150 to 150 is 300 degrees of travel and cannot be reached by moving the horn alone. Buying that travel means gearing the joint, which changes the degrees-to-pulse conversion chapter 64 built and needs the calibration of chapter 73 run again. Write the number down as a design note for the next arm, not a patch for this one.

She can now be handed a point and answer with a pose or a reason. What nothing in her produces yet is the point. The camera reports that a mug is in the frame and roughly where it sits across the image, and that is two of the three numbers this solver needs; how far away it is has never been measured. Chapter 95 asks a single lens for the third number.