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.
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.
Walk the easy direction first
# 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
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.
# 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.
# 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
# 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.
# 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.
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
- 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
k1andk2are 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.
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.